Compare commits
No commits in common. "main" and "v1.6.92" have entirely different histories.
13
.gitignore
vendored
13
.gitignore
vendored
@ -19,6 +19,7 @@ apply_update.cmd
|
|||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
.github/
|
.github/
|
||||||
|
docs/plans/
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -28,6 +29,7 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Forgejo deployment runtime files
|
||||||
deploy/forgejo/.env
|
deploy/forgejo/.env
|
||||||
deploy/forgejo/forgejo/
|
deploy/forgejo/forgejo/
|
||||||
deploy/forgejo/postgres/
|
deploy/forgejo/postgres/
|
||||||
@ -36,14 +38,3 @@ deploy/forgejo/caddy/config/
|
|||||||
deploy/forgejo/caddy/logs/
|
deploy/forgejo/caddy/logs/
|
||||||
deploy/forgejo/backups/
|
deploy/forgejo/backups/
|
||||||
.secrets
|
.secrets
|
||||||
|
|
||||||
*.log.old
|
|
||||||
*.bak
|
|
||||||
|
|
||||||
rust-postprocess/
|
|
||||||
electron-postprocess/
|
|
||||||
python-postprocess/
|
|
||||||
scripts/*.py
|
|
||||||
scripts/*.ps1
|
|
||||||
scripts/*.md
|
|
||||||
scripts/fix-library-renames.mjs
|
|
||||||
|
|||||||
362
README.md
362
README.md
@ -1,6 +1,6 @@
|
|||||||
# 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 with fast queue management, automatic extraction, and robust error handling.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@ -10,133 +10,83 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
|||||||
|
|
||||||
## Why this tool?
|
## Why this tool?
|
||||||
|
|
||||||
- JDownloader-style workflow with packages, progress, extraction, history, and clean post-processing.
|
- Familiar download-manager workflow: collect links, start, pause, resume, and finish cleanly.
|
||||||
- Multiple debrid accounts in one app, including provider order, automatic fallback, and per-hoster routing.
|
- Multiple debrid providers in one app, including automatic fallback.
|
||||||
- Built for large queues with session persistence, retries, reconnect handling, resume support, and integrity checks.
|
- Built for stability with large queues: session persistence, reconnect handling, resume support, and integrity verification.
|
||||||
- Includes an in-app updater for releases published on `git.24-music.de`.
|
|
||||||
|
|
||||||
## Supported providers
|
|
||||||
|
|
||||||
- AllDebrid API
|
|
||||||
- AllDebrid Web via browser login
|
|
||||||
- BestDebrid API
|
|
||||||
- BestDebrid Web via cookie import
|
|
||||||
- Debrid-Link with multi-key support
|
|
||||||
- DDownload login
|
|
||||||
- 1fichier API
|
|
||||||
- LinkSnappy login
|
|
||||||
- Mega-Debrid API
|
|
||||||
- Mega-Debrid Web
|
|
||||||
- Real-Debrid
|
|
||||||
|
|
||||||
## Core features
|
## Core features
|
||||||
|
|
||||||
### Queue and package handling
|
### Queue and download engine
|
||||||
|
|
||||||
- Package-based queue with item status, retries, ETA, speed, provider, and account label.
|
- Package-based queue with file status, progress, ETA, speed, and retry counters.
|
||||||
- Start, pause, stop, cancel, reset, rename, and delete for packages and items.
|
- Start, pause, stop, and cancel for both single items and full packages.
|
||||||
- Ctrl+Click multi-select and bulk actions.
|
- Multi-select via Ctrl+Click for batch operations on packages and items.
|
||||||
- Queue backup import/export as JSON.
|
|
||||||
- Context-menu export for selected packages or selected items as structured TXT re-import files.
|
|
||||||
- Duplicate handling when adding links: keep, skip, or overwrite.
|
- Duplicate handling when adding links: keep, skip, or overwrite.
|
||||||
- Optional start scheduling for a specific time.
|
- Session recovery after restart, including optional auto-resume.
|
||||||
- Session recovery after restart with optional auto-resume.
|
- Circuit breaker with escalating backoff cooldowns to handle provider outages gracefully.
|
||||||
- Optional auto-sorting by progress.
|
|
||||||
|
|
||||||
### Link collection
|
### Debrid and link handling
|
||||||
|
|
||||||
- Paste links directly into the collector.
|
- Supported providers: `realdebrid`, `megadebrid`, `bestdebrid`, `alldebrid`.
|
||||||
- Import `.txt` export files that preserve package names and optional per-file names.
|
- Configurable provider order: primary + secondary + tertiary.
|
||||||
- Clipboard watcher with automatic link detection.
|
- Optional automatic fallback to alternative providers on failures.
|
||||||
- `.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.
|
|
||||||
|
|
||||||
### Provider routing and fallback
|
### Extraction, cleanup, and quality
|
||||||
|
|
||||||
- Configurable provider order with primary, secondary, and tertiary fallback.
|
- JVM-based extraction backend using SevenZipJBinding + Zip4j (supports RAR, 7z, ZIP, and more).
|
||||||
- Optional automatic provider fallback on unrestrict/download failures.
|
- Automatic fallback to legacy UnRAR/7z CLI tools when JVM is unavailable.
|
||||||
- Per-hoster routing override, so specific hosters can always use a specific provider.
|
- Auto-extract with separate target directory and conflict strategies.
|
||||||
- Providers can be disabled without deleting stored account data.
|
- Hybrid extraction: simultaneous downloading and extracting with smart I/O priority throttling.
|
||||||
- Daily traffic limits per provider.
|
- Nested extraction: archives within archives are automatically extracted (one level deep).
|
||||||
- Debrid-Link per-key daily limits and per-key daily usage tracking.
|
- Pre-extraction disk space validation to prevent incomplete extracts.
|
||||||
|
- Right-click "Extract now" on any package with at least one completed item.
|
||||||
|
- Post-download integrity checks (`CRC32`, `MD5`, `SHA1`) with auto-retry on failures.
|
||||||
|
- Completed-item cleanup policy: `never`, `immediate`, `on_start`, `package_done`.
|
||||||
|
- Optional removal of link artifacts and sample files after extraction.
|
||||||
|
|
||||||
### Accounts and provider tools
|
### Auto-rename
|
||||||
|
|
||||||
- Central Accounts view with account type, status, info, access data, and actions.
|
- Automatic renaming of extracted files based on series/episode patterns.
|
||||||
- BestDebrid cookie import directly from a Netscape cookies file.
|
- Multi-episode token parsing for batch renames.
|
||||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
|
||||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
|
||||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
|
||||||
- Reset button for stored account column widths in the Accounts table.
|
|
||||||
|
|
||||||
### Download engine
|
### UI and progress
|
||||||
|
|
||||||
- Parallel downloads with resumable transfers when supported.
|
- Visual progress bars with percentage overlay for packages and individual items.
|
||||||
- Reconnect handling with configurable wait time.
|
- Real-time bandwidth chart showing current download speeds.
|
||||||
- Circuit-breaker style cooldown and retry handling for provider issues.
|
- Persistent download counters: all-time totals and per-session statistics.
|
||||||
- Global speed limit or per-download speed limit mode.
|
- Download history for completed packages.
|
||||||
- Bandwidth schedules with time windows and speed caps.
|
- Vertical sidebar with organized settings tabs.
|
||||||
- Live bandwidth chart and session statistics.
|
- Hoster display showing both the original source and the debrid provider used.
|
||||||
- Persistent all-time download counter.
|
|
||||||
|
|
||||||
### Extraction and post-processing
|
### Convenience and automation
|
||||||
|
|
||||||
- Automatic extraction after download.
|
- Clipboard watcher for automatic link detection.
|
||||||
- Extraction can continue even when the session is stopped or after app restart.
|
- Minimize-to-tray with tray menu controls.
|
||||||
- Hybrid download + extract workflow.
|
- Speed limits globally or per download.
|
||||||
- Extraction backend using native tools by default, with JVM sidecar support available.
|
- Bandwidth schedules for time-based speed profiles.
|
||||||
- Supports common archive formats including RAR, ZIP, and 7z.
|
- Built-in auto-updater via `git.24-music.de` Releases.
|
||||||
- Nested extraction for archives found inside extracted output.
|
- Long path support (>260 characters) on Windows.
|
||||||
- Conflict handling: overwrite, skip, rename, or ask.
|
|
||||||
- Disk-space validation before extraction.
|
|
||||||
- Package-scoped password reuse for multi-archive sets.
|
|
||||||
- Optional cleanup of downloaded archives after extraction.
|
|
||||||
- Optional cleanup of link artifacts and sample files after extraction.
|
|
||||||
- Optional flat MKV collection folder after package completion.
|
|
||||||
|
|
||||||
### Auto-rename and media cleanup
|
|
||||||
|
|
||||||
- Auto-rename for extracted scene-style files based on folder/source naming.
|
|
||||||
- Multi-episode token parsing.
|
|
||||||
- Handles compact episode tokens like `s02e01` directly attached to the title.
|
|
||||||
- Optional skip of already extracted packages on start.
|
|
||||||
|
|
||||||
### Integrity, history, and backup
|
|
||||||
|
|
||||||
- Optional integrity verification with `CRC32`, `MD5`, and `SHA1`.
|
|
||||||
- Download history with package details, duration, size, provider, and target folder.
|
|
||||||
- Backup export/import for restoring app state.
|
|
||||||
- Persistent config, session, and history files in the Electron `userData` directory.
|
|
||||||
|
|
||||||
### UI and desktop integration
|
|
||||||
|
|
||||||
- Downloads, history, statistics, and settings tabs.
|
|
||||||
- Progress bars for packages and single items.
|
|
||||||
- Hoster/provider display showing both source and effective debrid account.
|
|
||||||
- Minimize-to-tray support.
|
|
||||||
- Dark/light theme setting.
|
|
||||||
- Long path support on Windows.
|
|
||||||
- Default startup window size of `1920x1080`.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Prebuilt releases
|
### Option A: prebuilt releases (recommended)
|
||||||
|
|
||||||
1. Download the latest installer or portable build from the releases page.
|
1. Download a release from the `git.24-music.de` Releases page.
|
||||||
2. Start the app.
|
2. Run the installer or portable build.
|
||||||
3. Add your provider credentials in `Settings > Accounts`.
|
3. Add your debrid tokens in Settings.
|
||||||
|
|
||||||
Releases: [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases)
|
Releases: `https://git.24-music.de/Administrator/real-debrid-downloader/releases`
|
||||||
|
|
||||||
### Build from source
|
### Option B: build from source
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
|
|
||||||
- Node.js `20+`
|
- Node.js `20+` (recommended `22+`)
|
||||||
- npm
|
- npm
|
||||||
- Windows `10/11`
|
- Windows `10/11` (for packaging and regular desktop use)
|
||||||
- Java Runtime `8+` for the optional JVM extraction backend
|
- Java Runtime `8+` (for SevenZipJBinding sidecar backend)
|
||||||
- Optional native extraction tools: 7-Zip / WinRAR / UnRAR
|
- Optional fallback: 7-Zip/UnRAR if you force legacy extraction mode
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@ -147,160 +97,106 @@ npm run dev
|
|||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `npm run dev` | Starts Vite, tsup watchers, and Electron in development mode |
|
| `npm run dev` | Starts main process, renderer, and Electron in dev 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 app locally 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 end-to-end self-checks |
|
||||||
| `npm run release:win` | Builds Windows installer and portable EXE |
|
| `npm run release:win` | Creates Windows installer and portable build |
|
||||||
| `npm run release:gitea -- <version> [notes]` | Builds, tags, and uploads a release to `git.24-music.de` |
|
| `npm run release:gitea -- <version> [notes]` | One-command version bump + build + tag + release upload to `git.24-music.de` |
|
||||||
| `npm run release:forgejo -- <version> [notes]` | Alias for the same release workflow |
|
| `npm run release:codeberg -- <version> [notes]` | Legacy path for old Codeberg workflow |
|
||||||
|
|
||||||
|
### One-command git.24-music release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run release:gitea -- 1.6.31 "- Maintenance update"
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
|
||||||
|
1. Bump `package.json` version.
|
||||||
|
2. Build setup/portable artifacts (`npm run release:win`).
|
||||||
|
3. Commit and push `main` to your `git.24-music.de` remote.
|
||||||
|
4. Create and push tag `v<version>`.
|
||||||
|
5. Create/update the Gitea release and upload required assets.
|
||||||
|
|
||||||
|
Required once before release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add gitea https://git.24-music.de/<user>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell token setup:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:GITEA_TOKEN="<dein-token>"
|
||||||
|
```
|
||||||
|
|
||||||
## Typical workflow
|
## Typical workflow
|
||||||
|
|
||||||
1. Add one or more provider accounts in `Settings > Accounts`.
|
1. Add provider tokens in Settings.
|
||||||
2. Configure provider order, fallback, and optional hoster routing.
|
2. Paste/import links or `.dlc` containers.
|
||||||
3. Paste links or import `.dlc` files.
|
3. Optionally set package names, target folders, extraction, and cleanup rules.
|
||||||
4. Adjust package names, target folders, extraction, and cleanup settings if needed.
|
4. Start the queue and monitor progress in the Downloads tab.
|
||||||
5. Start the queue and monitor downloads, extraction, and provider status.
|
5. Review integrity results and summary after completion.
|
||||||
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
|
## Project structure
|
||||||
|
|
||||||
- `src/main` - Electron main process, download engine, provider clients, updater, storage
|
- `src/main` - Electron main process, queue/download/provider logic
|
||||||
- `src/preload` - secure IPC bridge
|
- `src/preload` - secure IPC bridge between main and renderer
|
||||||
- `src/renderer` - React UI
|
- `src/renderer` - React UI
|
||||||
- `src/shared` - shared types and IPC contracts
|
- `src/shared` - shared types and IPC contracts
|
||||||
- `tests` - unit and integration-style tests
|
- `tests` - unit tests and self-check tests
|
||||||
- `resources/extractor-jvm` - optional JVM extraction runtime
|
- `resources/extractor-jvm` - SevenZipJBinding + Zip4j sidecar JAR and native libraries
|
||||||
- `scripts` - release and build helpers
|
|
||||||
|
|
||||||
## Data and logs
|
## Data and logs
|
||||||
|
|
||||||
Runtime files are stored in Electron's `userData` directory, including:
|
The app stores runtime files in Electron's `userData` directory, including:
|
||||||
|
|
||||||
- `rd_downloader_config.json`
|
- `rd_downloader_config.json`
|
||||||
- `rd_session_state.json`
|
- `rd_session_state.json`
|
||||||
- `rd_history.json`
|
|
||||||
- `rd_downloader.log`
|
- `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
|
## Troubleshooting
|
||||||
|
|
||||||
- Provider does not work: verify credentials, enabled state, provider order, and daily limits.
|
- Download does not start: verify token and selected provider in Settings.
|
||||||
- Debrid-Link quota looks wrong: open the API-key statistics popup and check the Rapidgator quota for the affected key.
|
- Extraction fails: check archive passwords and native extractor installation (7-Zip/WinRAR). Optional JVM extractor can be forced with `RD_EXTRACT_BACKEND=jvm`.
|
||||||
- Extraction fails: verify passwords and installed extraction tools. The native backend is the default; JVM extraction is optional.
|
- Very slow downloads: check active speed limit and bandwidth schedules.
|
||||||
- Downloads stall: check active speed limits, bandwidth schedules, reconnect settings, and provider health.
|
- Unexpected interruptions: enable reconnect and fallback providers.
|
||||||
- Accounts table looks misaligned on one machine: use `Spalten zuruecksetzen` in the Accounts view to clear the locally stored column widths.
|
- Stalled downloads: the app auto-detects stalls within 10 seconds and retries automatically.
|
||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
Detailed release history is published on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
|
Release history is available on [git.24-music.de Releases](https://git.24-music.de/Administrator/real-debrid-downloader/releases).
|
||||||
|
|
||||||
|
### v1.6.61 (2026-03-05)
|
||||||
|
|
||||||
|
- Fixed leftover empty package folders in `Downloader Unfertig` after successful extraction.
|
||||||
|
- Resume marker files (`.rd_extract_progress*.json`) are now treated as ignorable for empty-folder cleanup.
|
||||||
|
- Deferred post-processing now clears resume markers before running empty-directory removal.
|
||||||
|
|
||||||
|
### v1.6.60 (2026-03-05)
|
||||||
|
|
||||||
|
- Added package-scoped password cache for extraction: once the first archive in a package is solved, following archives in the same package reuse that password first.
|
||||||
|
- Kept fallback behavior intact (`""` and other candidates are still tested), but moved empty-password probing behind the learned password to reduce per-archive delays.
|
||||||
|
- Added cache invalidation on real `wrong_password` failures so stale passwords are automatically discarded.
|
||||||
|
|
||||||
|
### v1.6.59 (2026-03-05)
|
||||||
|
|
||||||
|
- Switched default extraction backend to native tools (`legacy`) for more stable archive-to-archive flow.
|
||||||
|
- Prioritized 7-Zip as primary native extractor, with WinRAR/UnRAR as fallback.
|
||||||
|
- JVM extractor remains available as opt-in via `RD_EXTRACT_BACKEND=jvm`.
|
||||||
|
|
||||||
|
### v1.6.58 (2026-03-05)
|
||||||
|
|
||||||
|
- Fixed extraction progress oscillation (`1% -> 100% -> 1%` loops) during password retries.
|
||||||
|
- Kept strict archive completion logic, but normalized in-progress archive percent to avoid false visual done states before real completion.
|
||||||
|
|
||||||
|
### v1.6.57 (2026-03-05)
|
||||||
|
|
||||||
|
- Fixed extraction flow so archives are marked done only on real completion, not on temporary `100%` progress spikes.
|
||||||
|
- Improved password handling: after the first successful archive, the discovered password is prioritized for subsequent archives.
|
||||||
|
- Fixed progress parsing for password retries (reset/restart handling), reducing visible and real gaps between archive extractions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.45",
|
"version": "1.6.90",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.45",
|
"version": "1.6.90",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.190",
|
"version": "1.6.92",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -98,12 +98,13 @@ public final class JBindExtractorMain {
|
|||||||
System.out.flush();
|
System.out.flush();
|
||||||
}
|
}
|
||||||
} catch (IOException ignored) {
|
} catch (IOException ignored) {
|
||||||
|
// stdin closed — parent process exited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
|
private static ExtractionRequest parseDaemonRequest(String jsonLine) {
|
||||||
|
// Minimal JSON parsing without external dependencies.
|
||||||
|
// Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]}
|
||||||
ExtractionRequest request = new ExtractionRequest();
|
ExtractionRequest request = new ExtractionRequest();
|
||||||
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
|
request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
|
||||||
request.targetDir = new File(extractJsonString(jsonLine, "target"));
|
request.targetDir = new File(extractJsonString(jsonLine, "target"));
|
||||||
@ -115,7 +116,7 @@ public final class JBindExtractorMain {
|
|||||||
if (backend.length() > 0) {
|
if (backend.length() > 0) {
|
||||||
request.backend = Backend.fromValue(backend);
|
request.backend = Backend.fromValue(backend);
|
||||||
}
|
}
|
||||||
|
// Parse passwords array
|
||||||
int pwStart = jsonLine.indexOf("\"passwords\"");
|
int pwStart = jsonLine.indexOf("\"passwords\"");
|
||||||
if (pwStart >= 0) {
|
if (pwStart >= 0) {
|
||||||
int arrStart = jsonLine.indexOf('[', pwStart);
|
int arrStart = jsonLine.indexOf('[', pwStart);
|
||||||
@ -160,7 +161,7 @@ public final class JBindExtractorMain {
|
|||||||
for (int i = from; i < s.length(); i++) {
|
for (int i = from; i < s.length(); i++) {
|
||||||
char c = s.charAt(i);
|
char c = s.charAt(i);
|
||||||
if (c == '\\') {
|
if (c == '\\') {
|
||||||
i++;
|
i++; // skip escaped character
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c == '"') return i;
|
if (c == '"') return i;
|
||||||
@ -366,6 +367,7 @@ public final class JBindExtractorMain {
|
|||||||
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
|
||||||
long totalUnits = 0;
|
long totalUnits = 0;
|
||||||
boolean encrypted = false;
|
boolean encrypted = false;
|
||||||
List<Integer> fileIndices = new ArrayList<Integer>();
|
List<Integer> fileIndices = new ArrayList<Integer>();
|
||||||
@ -389,7 +391,7 @@ public final class JBindExtractorMain {
|
|||||||
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
|
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
|
||||||
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
|
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
|
// ignore encrypted flag read issues
|
||||||
}
|
}
|
||||||
|
|
||||||
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
|
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
|
||||||
@ -398,12 +400,12 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
|
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
|
||||||
fileIndices.add(i);
|
fileIndices.add(i);
|
||||||
outputFiles.add(output);
|
outputFiles.add(output); // null if skipped
|
||||||
fileSizes.add(itemSize);
|
fileSizes.add(itemSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fileIndices.isEmpty()) {
|
if (fileIndices.isEmpty()) {
|
||||||
|
// All items are folders or skipped
|
||||||
ProgressTracker progress = new ProgressTracker(1);
|
ProgressTracker progress = new ProgressTracker(1);
|
||||||
progress.emitStart();
|
progress.emitStart();
|
||||||
progress.emitDone();
|
progress.emitDone();
|
||||||
@ -413,16 +415,19 @@ public final class JBindExtractorMain {
|
|||||||
ProgressTracker progress = new ProgressTracker(totalUnits);
|
ProgressTracker progress = new ProgressTracker(totalUnits);
|
||||||
progress.emitStart();
|
progress.emitStart();
|
||||||
|
|
||||||
|
// Build index array for bulk extract
|
||||||
int[] indices = new int[fileIndices.size()];
|
int[] indices = new int[fileIndices.size()];
|
||||||
for (int i = 0; i < fileIndices.size(); i++) {
|
for (int i = 0; i < fileIndices.size(); i++) {
|
||||||
indices[i] = fileIndices.get(i);
|
indices[i] = fileIndices.get(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map from archive index to our position in fileIndices/outputFiles
|
||||||
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
|
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
|
||||||
for (int i = 0; i < fileIndices.size(); i++) {
|
for (int i = 0; i < fileIndices.size(); i++) {
|
||||||
indexToPos.put(fileIndices.get(i), i);
|
indexToPos.put(fileIndices.get(i), i);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk extraction state
|
||||||
final boolean encryptedFinal = encrypted;
|
final boolean encryptedFinal = encrypted;
|
||||||
final String effectivePassword = password == null ? "" : password;
|
final String effectivePassword = password == null ? "" : password;
|
||||||
final File[] currentOutput = new File[1];
|
final File[] currentOutput = new File[1];
|
||||||
@ -669,7 +674,7 @@ public final class JBindExtractorMain {
|
|||||||
if (entry.length() == 0) {
|
if (entry.length() == 0) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
// Sanitize Windows special characters from each path segment
|
||||||
String[] segments = entry.split("/", -1);
|
String[] segments = entry.split("/", -1);
|
||||||
StringBuilder sanitized = new StringBuilder();
|
StringBuilder sanitized = new StringBuilder();
|
||||||
for (int i = 0; i < segments.length; i++) {
|
for (int i = 0; i < segments.length; i++) {
|
||||||
@ -703,7 +708,7 @@ public final class JBindExtractorMain {
|
|||||||
if (Files.isSymbolicLink(file.toPath())) {
|
if (Files.isSymbolicLink(file.toPath())) {
|
||||||
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
|
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
|
||||||
}
|
}
|
||||||
|
// Also check parent directories for symlinks
|
||||||
File parent = file.getParentFile();
|
File parent = file.getParentFile();
|
||||||
while (parent != null) {
|
while (parent != null) {
|
||||||
if (Files.isSymbolicLink(parent.toPath())) {
|
if (Files.isSymbolicLink(parent.toPath())) {
|
||||||
@ -874,6 +879,12 @@ public final class JBindExtractorMain {
|
|||||||
private final List<String> passwords = new ArrayList<String>();
|
private final List<String> passwords = new ArrayList<String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk extraction callback that implements both IArchiveExtractCallback and
|
||||||
|
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
|
||||||
|
* per-item extractSlow() is critical for performance — solid RAR archives
|
||||||
|
* otherwise re-decode from the beginning for every single item.
|
||||||
|
*/
|
||||||
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
|
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
|
||||||
private final IInArchive archive;
|
private final IInArchive archive;
|
||||||
private final Map<Integer, Integer> indexToPos;
|
private final Map<Integer, Integer> indexToPos;
|
||||||
@ -919,12 +930,12 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setTotal(long total) {
|
public void setTotal(long total) {
|
||||||
|
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setCompleted(long complete) {
|
public void setCompleted(long complete) {
|
||||||
|
// Not used — we track per-write progress
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -979,7 +990,7 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void prepareOperation(ExtractAskMode extractAskMode) {
|
public void prepareOperation(ExtractAskMode extractAskMode) {
|
||||||
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1000,7 +1011,7 @@ public final class JBindExtractorMain {
|
|||||||
currentOutput[0].setLastModified(modified.getTime());
|
currentOutput[0].setLastModified(modified.getTime());
|
||||||
}
|
}
|
||||||
} catch (Throwable ignored) {
|
} catch (Throwable ignored) {
|
||||||
|
// best effort
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -1168,12 +1179,12 @@ public final class JBindExtractorMain {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setTotal(Long files, Long bytes) {
|
public void setTotal(Long files, Long bytes) {
|
||||||
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void setCompleted(Long files, Long bytes) {
|
public void setCompleted(Long files, Long bytes) {
|
||||||
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -1185,7 +1196,8 @@ public final class JBindExtractorMain {
|
|||||||
if (filename == null || filename.trim().length() == 0) {
|
if (filename == null || filename.trim().length() == 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Always resolve relative to the archive's parent directory.
|
||||||
|
// Never accept absolute paths to prevent path traversal.
|
||||||
String baseName = new File(filename).getName();
|
String baseName = new File(filename).getName();
|
||||||
if (archiveDir != null) {
|
if (archiveDir != null) {
|
||||||
File relative = new File(archiveDir, baseName);
|
File relative = new File(archiveDir, baseName);
|
||||||
|
|||||||
@ -66,6 +66,8 @@ async function callRealDebrid(link) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// megaCookie is intentionally cached at module scope so that multiple
|
||||||
|
// callMegaDebrid() invocations reuse the same session cookie.
|
||||||
async function callMegaDebrid(link) {
|
async function callMegaDebrid(link) {
|
||||||
if (!megaCookie) {
|
if (!megaCookie) {
|
||||||
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
||||||
|
|||||||
@ -116,6 +116,7 @@ function getGiteaRepo() {
|
|||||||
}
|
}
|
||||||
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
|
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
|
||||||
} catch {
|
} catch {
|
||||||
|
// try next remote
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -255,78 +256,53 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
|||||||
target_commitish: "main",
|
target_commitish: "main",
|
||||||
name: tag,
|
name: tag,
|
||||||
body: notes || `Release ${tag}`,
|
body: notes || `Release ${tag}`,
|
||||||
draft: true,
|
draft: false,
|
||||||
prerelease: false
|
prerelease: false
|
||||||
};
|
};
|
||||||
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
|
const created = await apiRequest("POST", `${baseApi}/releases`, authHeader, JSON.stringify(payload));
|
||||||
if (created.ok) {
|
if (!created.ok) {
|
||||||
return created.body;
|
throw new Error(`Failed to create release (${created.status}): ${JSON.stringify(created.body)}`);
|
||||||
}
|
}
|
||||||
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
return created.body;
|
||||||
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) {
|
async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, files) {
|
||||||
const MAX_ATTEMPTS = 3;
|
|
||||||
for (const fileName of files) {
|
for (const fileName of files) {
|
||||||
const filePath = path.join(releaseDir, fileName);
|
const filePath = path.join(releaseDir, fileName);
|
||||||
const fileSize = fs.statSync(filePath).size;
|
const fileSize = fs.statSync(filePath).size;
|
||||||
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
// Stream large files instead of loading them entirely into memory
|
||||||
const fileStream = fs.createReadStream(filePath);
|
const fileStream = fs.createReadStream(filePath);
|
||||||
let response;
|
const response = await fetch(uploadUrl, {
|
||||||
try {
|
method: "POST",
|
||||||
response = await fetch(uploadUrl, {
|
headers: {
|
||||||
method: "POST",
|
Accept: "application/json",
|
||||||
headers: {
|
Authorization: authHeader,
|
||||||
Accept: "application/json",
|
"Content-Type": "application/octet-stream",
|
||||||
Authorization: authHeader,
|
"Content-Length": String(fileSize)
|
||||||
"Content-Type": "application/octet-stream",
|
},
|
||||||
"Content-Length": String(fileSize)
|
body: fileStream,
|
||||||
},
|
duplex: "half"
|
||||||
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();
|
const text = await response.text();
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = text ? JSON.parse(text) : null;
|
parsed = text ? JSON.parse(text) : null;
|
||||||
} catch {
|
} catch {
|
||||||
parsed = text;
|
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)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
process.stdout.write(`Uploaded: ${fileName}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (response.status === 409 || response.status === 422) {
|
||||||
|
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Asset upload failed for ${fileName} (${response.status}): ${JSON.stringify(parsed)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,43 +322,31 @@ async function main() {
|
|||||||
const releaseNotes = args.notes || `- Release ${tag}`;
|
const releaseNotes = args.notes || `- Release ${tag}`;
|
||||||
const repo = getGiteaRepo();
|
const repo = getGiteaRepo();
|
||||||
|
|
||||||
const tagExists = spawnSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], { cwd: process.cwd(), stdio: "ignore" }).status === 0;
|
ensureNoTrackedChanges();
|
||||||
|
ensureTagMissing(tag);
|
||||||
|
|
||||||
if (tagExists) {
|
if (args.dryRun) {
|
||||||
process.stdout.write(`Tag ${tag} already exists locally — skipping version bump and git operations (recovery mode).\n`);
|
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
|
||||||
} else {
|
return;
|
||||||
ensureNoTrackedChanges();
|
|
||||||
|
|
||||||
if (args.dryRun) {
|
|
||||||
process.stdout.write(`Dry run: would release ${tag}. No changes made.\n`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePackageVersion(rootDir, version);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePackageVersion(rootDir, version);
|
||||||
|
|
||||||
process.stdout.write(`Building release artifacts for ${tag}...\n`);
|
process.stdout.write(`Building release artifacts for ${tag}...\n`);
|
||||||
run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args);
|
run(NPM_RELEASE_WIN.command, NPM_RELEASE_WIN.args);
|
||||||
const assets = ensureAssetsExist(rootDir, version);
|
const assets = ensureAssetsExist(rootDir, version);
|
||||||
|
|
||||||
if (!tagExists) {
|
run("git", ["add", "package.json"]);
|
||||||
run("git", ["add", "package.json"]);
|
run("git", ["commit", "-m", `Release ${tag}`]);
|
||||||
run("git", ["commit", "-m", `Release ${tag}`]);
|
run("git", ["push", repo.remote, "main"]);
|
||||||
run("git", ["push", repo.remote, "main"]);
|
run("git", ["tag", tag]);
|
||||||
run("git", ["tag", tag]);
|
run("git", ["push", repo.remote, tag]);
|
||||||
run("git", ["push", repo.remote, tag]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = getAuthHeader(repo.host);
|
const authHeader = getAuthHeader(repo.host);
|
||||||
const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`;
|
const baseApi = `${repo.baseUrl}/api/v1/repos/${repo.owner}/${repo.repo}`;
|
||||||
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
|
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
|
||||||
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
|
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`);
|
process.stdout.write(`Release published: ${release.html_url || `${repo.baseUrl}/${repo.owner}/${repo.repo}/releases/tag/${tag}`}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,197 +0,0 @@
|
|||||||
import type { AppSettings, DebridAccountStatus } from "../shared/types";
|
|
||||||
import { parseMegaDebridAccounts, type MegaDebridAccountEntry } from "../shared/mega-debrid-accounts";
|
|
||||||
import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/debrid-link-keys";
|
|
||||||
import { logger } from "./logger";
|
|
||||||
import { compactErrorText } from "./utils";
|
|
||||||
|
|
||||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
|
||||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
|
||||||
const CHECK_USER_AGENT =
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
||||||
const CHECK_TIMEOUT_MS = 20000;
|
|
||||||
|
|
||||||
function timeoutSignal(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
||||||
const timeout = AbortSignal.timeout(ms);
|
|
||||||
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonSafe(text: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text) as unknown;
|
|
||||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
|
||||||
if (premiumUntilMs == null) {
|
|
||||||
return "Premium-Status unbekannt";
|
|
||||||
}
|
|
||||||
if (premiumUntilMs <= 0) {
|
|
||||||
return "Kein Premium";
|
|
||||||
}
|
|
||||||
const remainingMs = premiumUntilMs - now;
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
return "Premium abgelaufen";
|
|
||||||
}
|
|
||||||
const days = Math.floor(remainingMs / (24 * 60 * 60 * 1000));
|
|
||||||
if (days >= 1) {
|
|
||||||
return `Premium noch ${days} Tag${days === 1 ? "" : "e"}`;
|
|
||||||
}
|
|
||||||
const hours = Math.max(1, Math.floor(remainingMs / (60 * 60 * 1000)));
|
|
||||||
return `Premium noch ${hours} Std`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkMegaDebridAccount(
|
|
||||||
account: MegaDebridAccountEntry,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
now = Date.now()
|
|
||||||
): Promise<DebridAccountStatus> {
|
|
||||||
const base: DebridAccountStatus = {
|
|
||||||
accountId: account.id,
|
|
||||||
provider: "megadebrid",
|
|
||||||
label: account.label,
|
|
||||||
maskedLogin: account.maskedLogin,
|
|
||||||
valid: false,
|
|
||||||
isPremium: false,
|
|
||||||
premiumUntilMs: null,
|
|
||||||
message: "",
|
|
||||||
checkedAt: now
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const url = `${MEGA_DEBRID_API}?action=connectUser&login=${encodeURIComponent(account.login)}&password=${encodeURIComponent(account.password)}`;
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { "User-Agent": CHECK_USER_AGENT },
|
|
||||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
const payload = parseJsonSafe(text);
|
|
||||||
if (!response.ok || !payload) {
|
|
||||||
return { ...base, message: `Login fehlgeschlagen (HTTP ${response.status})` };
|
|
||||||
}
|
|
||||||
if (payload.response_code !== "ok") {
|
|
||||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
|
||||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
|
||||||
}
|
|
||||||
const vipEndRaw = Number(payload.vip_end || 0);
|
|
||||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
|
||||||
const isPremium = premiumUntilMs > now;
|
|
||||||
const email = String(payload.email || "").trim() || undefined;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
valid: true,
|
|
||||||
isPremium,
|
|
||||||
premiumUntilMs,
|
|
||||||
email,
|
|
||||||
message: formatRemaining(premiumUntilMs, now)
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errText = compactErrorText(error);
|
|
||||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkDebridLinkKey(
|
|
||||||
key: DebridLinkApiKeyEntry,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
now = Date.now()
|
|
||||||
): Promise<DebridAccountStatus> {
|
|
||||||
const base: DebridAccountStatus = {
|
|
||||||
accountId: key.id,
|
|
||||||
provider: "debridlink",
|
|
||||||
label: key.label,
|
|
||||||
maskedLogin: key.masked,
|
|
||||||
valid: false,
|
|
||||||
isPremium: false,
|
|
||||||
premiumUntilMs: null,
|
|
||||||
message: "",
|
|
||||||
checkedAt: now
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${DEBRID_LINK_API}/account/infos`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${key.token}`,
|
|
||||||
"User-Agent": CHECK_USER_AGENT
|
|
||||||
},
|
|
||||||
signal: timeoutSignal(signal, CHECK_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
const payload = parseJsonSafe(text);
|
|
||||||
if (!response.ok || !payload) {
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
|
||||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
|
||||||
}
|
|
||||||
return { ...base, message: `Pruefung fehlgeschlagen (HTTP ${response.status})` };
|
|
||||||
}
|
|
||||||
if (payload.success === false) {
|
|
||||||
const reason = String(payload.error || "Key abgelehnt");
|
|
||||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
|
||||||
}
|
|
||||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
|
||||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
|
||||||
const accountType = Number(value.accountType || 0);
|
|
||||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
|
||||||
const isPremium = premiumUntilMs > now || accountType > 0;
|
|
||||||
const username = String(value.username || "").trim() || undefined;
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
valid: true,
|
|
||||||
isPremium,
|
|
||||||
premiumUntilMs: premiumUntilMs > 0 ? premiumUntilMs : (accountType > 0 ? null : 0),
|
|
||||||
email: username,
|
|
||||||
message: premiumUntilMs > 0
|
|
||||||
? formatRemaining(premiumUntilMs, now)
|
|
||||||
: (accountType > 0 ? "Premium aktiv" : "Kein Premium (Free)")
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errText = compactErrorText(error);
|
|
||||||
const aborted = signal?.aborted || /aborted/i.test(errText);
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
message: aborted ? "Pruefung abgebrochen" : `Pruefung fehlgeschlagen: ${errText}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAllDebridAccounts(
|
|
||||||
settings: AppSettings,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<DebridAccountStatus[]> {
|
|
||||||
const now = Date.now();
|
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
|
||||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
|
||||||
|
|
||||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
|
||||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
|
||||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = await runWithConcurrency(taskFns, CHECK_CONCURRENCY);
|
|
||||||
logger.info(
|
|
||||||
`Account-Check abgeschlossen: ${results.length} Accounts geprueft ` +
|
|
||||||
`(${results.filter((r) => r.valid).length} gueltig, ${results.filter((r) => r.isPremium).length} premium)`
|
|
||||||
);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHECK_CONCURRENCY = 4;
|
|
||||||
|
|
||||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
|
||||||
const results: T[] = new Array(taskFns.length);
|
|
||||||
let nextIndex = 0;
|
|
||||||
const worker = async (): Promise<void> => {
|
|
||||||
while (nextIndex < taskFns.length) {
|
|
||||||
const current = nextIndex;
|
|
||||||
nextIndex += 1;
|
|
||||||
results[current] = await taskFns[current]();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const workers = Array.from({ length: Math.min(limit, taskFns.length) }, () => worker());
|
|
||||||
await Promise.all(workers);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
|
||||||
import type { RotationEvent } from "../shared/types";
|
|
||||||
|
|
||||||
export type RotationItemSink = (event: RotationEvent) => void;
|
|
||||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
|
||||||
|
|
||||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
|
||||||
return rotationItemContext.run(sink, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
const ROTATION_EVENT_RING_MAX = 60;
|
|
||||||
const rotationEventRing: RotationEvent[] = [];
|
|
||||||
let rotationEventSeq = 0;
|
|
||||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
|
||||||
|
|
||||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
|
||||||
rotationEventListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
|
||||||
const slice = rotationEventRing.slice(-limit);
|
|
||||||
slice.reverse();
|
|
||||||
return slice;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isUiRelevantRotationEvent(event: string): boolean {
|
|
||||||
return event !== "TEST";
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushRotationEvent(
|
|
||||||
level: RotationLevel,
|
|
||||||
provider: string,
|
|
||||||
accountLabel: string,
|
|
||||||
event: string,
|
|
||||||
fields?: Record<string, unknown>,
|
|
||||||
at = Date.now()
|
|
||||||
): void {
|
|
||||||
rotationEventSeq += 1;
|
|
||||||
const entry: RotationEvent = {
|
|
||||||
id: `rot_${at}_${rotationEventSeq}`,
|
|
||||||
at,
|
|
||||||
level,
|
|
||||||
provider,
|
|
||||||
accountLabel,
|
|
||||||
event,
|
|
||||||
reason: fields && fields.reason != null ? String(fields.reason) : undefined,
|
|
||||||
category: fields && fields.category != null ? String(fields.category) : undefined,
|
|
||||||
cooldownSec: fields && fields.cooldownSec != null ? Number(fields.cooldownSec) || 0 : undefined,
|
|
||||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
const itemSink = rotationItemContext.getStore();
|
|
||||||
if (itemSink) {
|
|
||||||
try {
|
|
||||||
itemSink(entry);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isUiRelevantRotationEvent(event)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rotationEventRing.push(entry);
|
|
||||||
if (rotationEventRing.length > ROTATION_EVENT_RING_MAX) {
|
|
||||||
rotationEventRing.splice(0, rotationEventRing.length - ROTATION_EVENT_RING_MAX);
|
|
||||||
}
|
|
||||||
if (rotationEventListener) {
|
|
||||||
try {
|
|
||||||
rotationEventListener(entry);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROTATION_LOG_MAX_FILE_BYTES = Number(process.env.RD_ACCOUNT_ROTATION_LOG_MAX_BYTES || 5 * 1024 * 1024);
|
|
||||||
const ROTATION_LOG_RETENTION_DAYS = Number(process.env.RD_ACCOUNT_ROTATION_LOG_RETENTION_DAYS || 14);
|
|
||||||
|
|
||||||
let rotationLogPath: string | null = null;
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size < ROTATION_LOG_MAX_FILE_BYTES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
fs.renameSync(filePath, backup);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(backup);
|
|
||||||
const cutoff = Date.now() - ROTATION_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initAccountRotationLog(baseDir: string): void {
|
|
||||||
rotationLogPath = path.join(baseDir, "account-rotation.log");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(rotationLogPath), { recursive: true });
|
|
||||||
cleanupOldBackup(rotationLogPath);
|
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
rotateIfNeeded(rotationLogPath);
|
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(
|
|
||||||
rotationLogPath,
|
|
||||||
`=== Account-Rotation Log Start: ${logTimestamp()} ===\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
rotationLogPath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logAccountRotation(
|
|
||||||
level: RotationLevel,
|
|
||||||
provider: string,
|
|
||||||
accountLabel: string,
|
|
||||||
event: string,
|
|
||||||
fields?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
|
||||||
if (!rotationLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
rotateIfNeeded(rotationLogPath);
|
|
||||||
if (!fs.existsSync(rotationLogPath)) {
|
|
||||||
fs.writeFileSync(rotationLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
|
||||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAccountRotationLogPath(): string | null {
|
|
||||||
if (!rotationLogPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(rotationLogPath) ? rotationLogPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownAccountRotationLog(): void {
|
|
||||||
if (!rotationLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(
|
|
||||||
rotationLogPath,
|
|
||||||
`=== Account-Rotation Log Ende: ${logTimestamp()} ===\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
rotationLogPath = null;
|
|
||||||
}
|
|
||||||
@ -243,10 +243,12 @@ export class AllDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import v8 from "node:v8";
|
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
@ -20,38 +16,20 @@ import {
|
|||||||
UpdateInstallProgress,
|
UpdateInstallProgress,
|
||||||
UpdateInstallResult
|
UpdateInstallResult
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
|
|
||||||
import { importDlcContainers } from "./container";
|
import { importDlcContainers } from "./container";
|
||||||
import { APP_VERSION } from "./constants";
|
import { APP_VERSION } from "./constants";
|
||||||
import { DownloadManager } from "./download-manager";
|
import { DownloadManager } from "./download-manager";
|
||||||
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
|
import { fetchAllDebridHostInfo } from "./debrid";
|
||||||
import { checkAllDebridAccounts, checkMegaDebridAccount } from "./account-check";
|
|
||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
|
||||||
import { parseCollectorInput } from "./link-parser";
|
import { parseCollectorInput } from "./link-parser";
|
||||||
import { configureLogger, getLogFilePath, logger } from "./logger";
|
import { configureLogger, getLogFilePath, logger } from "./logger";
|
||||||
import { AllDebridWebFallback } from "./all-debrid-web";
|
import { AllDebridWebFallback } from "./all-debrid-web";
|
||||||
import { BestDebridWebFallback } from "./bestdebrid-web";
|
import { BestDebridWebFallback } from "./bestdebrid-web";
|
||||||
import { RealDebridWebFallback } from "./realdebrid-web";
|
import { RealDebridWebFallback } from "./realdebrid-web";
|
||||||
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
|
|
||||||
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
|
|
||||||
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
|
||||||
import { MegaWebFallback } from "./mega-web-fallback";
|
import { MegaWebFallback } from "./mega-web-fallback";
|
||||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage";
|
||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
import { startDebugServer, stopDebugServer } from "./debug-server";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
|
||||||
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
|
||||||
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
|
|
||||||
import { runStartupHealthCheck } from "./startup-health-check";
|
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "./link-export";
|
|
||||||
import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "./rename-log";
|
|
||||||
import { getDesktopRenameLogPath, initDesktopRenameLog, shutdownDesktopRenameLog } from "./desktop-rename-log";
|
|
||||||
import { buildAccountSummary, diffAccountSummary } from "./support-data";
|
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
|
||||||
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
|
|
||||||
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
|
|
||||||
|
|
||||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||||
@ -84,27 +62,11 @@ export class AppController {
|
|||||||
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
|
||||||
|
|
||||||
private autoResumePending = false;
|
private autoResumePending = false;
|
||||||
private runtimeStatsTimer: NodeJS.Timeout | null = null;
|
|
||||||
private lastMemoryWarnAt = 0;
|
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
configureLogger(this.storagePaths.baseDir);
|
configureLogger(this.storagePaths.baseDir);
|
||||||
initSessionLog(this.storagePaths.baseDir);
|
initSessionLog(this.storagePaths.baseDir);
|
||||||
initPackageLogs(this.storagePaths.baseDir);
|
|
||||||
initItemLogs(this.storagePaths.baseDir);
|
|
||||||
initAuditLog(this.storagePaths.baseDir);
|
|
||||||
initAccountRotationLog(this.storagePaths.baseDir);
|
|
||||||
initRenameLog(this.storagePaths.baseDir);
|
|
||||||
let desktopDir: string | null = null;
|
|
||||||
try {
|
|
||||||
desktopDir = app.getPath("desktop");
|
|
||||||
} catch {
|
|
||||||
desktopDir = null;
|
|
||||||
}
|
|
||||||
initDesktopRenameLog(desktopDir);
|
|
||||||
initTraceLog(this.storagePaths.baseDir);
|
|
||||||
this.settings = loadSettings(this.storagePaths);
|
this.settings = loadSettings(this.storagePaths);
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
|
||||||
const session = loadSession(this.storagePaths);
|
const session = loadSession(this.storagePaths);
|
||||||
this.megaWebFallback = new MegaWebFallback(() => ({
|
this.megaWebFallback = new MegaWebFallback(() => ({
|
||||||
login: this.settings.megaLogin,
|
login: this.settings.megaLogin,
|
||||||
@ -114,13 +76,13 @@ export class AppController {
|
|||||||
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
|
||||||
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
|
||||||
megaWebUnrestrict: (link: string, signal?: AbortSignal, account?: { login: string; password: string }) => this.megaWebFallback.unrestrict(link, signal, account),
|
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
|
||||||
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
|
||||||
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
|
||||||
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
|
||||||
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
invalidateMegaSession: () => this.megaWebFallback.invalidateSession(),
|
||||||
onHistoryEntry: (entry: HistoryEntry) => {
|
onHistoryEntry: (entry: HistoryEntry) => {
|
||||||
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry);
|
addHistoryEntry(this.storagePaths, entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.manager.on("state", (snapshot: UiSnapshot) => {
|
this.manager.on("state", (snapshot: UiSnapshot) => {
|
||||||
@ -128,45 +90,7 @@ export class AppController {
|
|||||||
});
|
});
|
||||||
logger.info(`App gestartet v${APP_VERSION}`);
|
logger.info(`App gestartet v${APP_VERSION}`);
|
||||||
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
logger.info(`Log-Datei: ${getLogFilePath()}`);
|
||||||
logAuditEvent("INFO", "App gestartet", {
|
|
||||||
appVersion: APP_VERSION,
|
|
||||||
runtimeDir: this.storagePaths.baseDir
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const report = runStartupHealthCheck(this.settings, this.storagePaths);
|
|
||||||
if (report.errorCount > 0 || report.warnCount > 0) {
|
|
||||||
logger.warn(`Health-Check: ${report.errorCount} Fehler, ${report.warnCount} Warnungen, ${report.infoCount} Info`);
|
|
||||||
} else {
|
|
||||||
logger.info(`Health-Check: alles OK (${report.infoCount} Info)`);
|
|
||||||
}
|
|
||||||
for (const finding of report.findings) {
|
|
||||||
const line = finding.hint
|
|
||||||
? `Health-Check [${finding.code}]: ${finding.message} — ${finding.hint}`
|
|
||||||
: `Health-Check [${finding.code}]: ${finding.message}`;
|
|
||||||
if (finding.severity === "ERROR") {
|
|
||||||
logger.error(line);
|
|
||||||
} else if (finding.severity === "WARN") {
|
|
||||||
logger.warn(line);
|
|
||||||
} else {
|
|
||||||
logger.info(line);
|
|
||||||
}
|
|
||||||
if (finding.severity !== "INFO") {
|
|
||||||
logAuditEvent(finding.severity, `Health-Check: ${finding.code}`, {
|
|
||||||
message: finding.message,
|
|
||||||
hint: finding.hint || ""
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Health-Check uebersprungen (Fehler): ${String((err as Error).message || err)}`);
|
|
||||||
}
|
|
||||||
startDebugServer(this.manager, this.storagePaths.baseDir);
|
startDebugServer(this.manager, this.storagePaths.baseDir);
|
||||||
this.runtimeStatsTimer = setInterval(() => {
|
|
||||||
this.manager.persistRuntimeStats();
|
|
||||||
this.settings = this.manager.getSettings();
|
|
||||||
this.checkMemoryPressure();
|
|
||||||
}, 60_000);
|
|
||||||
this.runtimeStatsTimer.unref?.();
|
|
||||||
|
|
||||||
if (this.settings.autoResumeOnStart) {
|
if (this.settings.autoResumeOnStart) {
|
||||||
const snapshot = this.manager.getSnapshot();
|
const snapshot = this.manager.getSnapshot();
|
||||||
@ -175,6 +99,8 @@ export class AppController {
|
|||||||
void this.manager.getStartConflicts().then((conflicts) => {
|
void this.manager.getStartConflicts().then((conflicts) => {
|
||||||
const hasConflicts = conflicts.length > 0;
|
const hasConflicts = conflicts.length > 0;
|
||||||
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
|
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
|
||||||
|
// If the onState handler is already set (renderer connected), start immediately.
|
||||||
|
// Otherwise mark as pending so the onState setter triggers the start.
|
||||||
if (this.onStateHandler) {
|
if (this.onStateHandler) {
|
||||||
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
|
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
|
||||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||||
@ -190,34 +116,6 @@ export class AppController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Early-warning for OOM on a long-running process. Measured against the V8
|
|
||||||
// heap_size_limit (the real ceiling at which the process is killed), NOT against
|
|
||||||
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
|
|
||||||
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
|
|
||||||
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
|
|
||||||
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
|
|
||||||
private checkMemoryPressure(): void {
|
|
||||||
try {
|
|
||||||
const mem = process.memoryUsage();
|
|
||||||
const heapLimit = v8.getHeapStatistics().heap_size_limit;
|
|
||||||
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
|
|
||||||
if (ratio < 0.9) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.lastMemoryWarnAt = now;
|
|
||||||
const mb = (bytes: number): number => Math.round(bytes / 1048576);
|
|
||||||
logger.warn(
|
|
||||||
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
|
|
||||||
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private hasAnyProviderToken(settings: AppSettings): boolean {
|
private hasAnyProviderToken(settings: AppSettings): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
settings.token.trim()
|
settings.token.trim()
|
||||||
@ -245,6 +143,7 @@ export class AppController {
|
|||||||
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
|
||||||
logger.info("Auto-Resume beim Start aktiviert");
|
logger.info("Auto-Resume beim Start aktiviert");
|
||||||
} else {
|
} else {
|
||||||
|
// Trigger pending extractions without starting the session
|
||||||
this.manager.triggerIdleExtractions();
|
this.manager.triggerIdleExtractions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -262,68 +161,6 @@ export class AppController {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getAuditLogPath(): string | null {
|
|
||||||
return getAuditLogPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRenameLogPath(): string | null {
|
|
||||||
return getRenameLogPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDesktopRenameLogPath(): string | null {
|
|
||||||
return getDesktopRenameLogPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTraceLogPath(): string | null {
|
|
||||||
return getTraceLogPath();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getTraceConfig(): SupportTraceConfig {
|
|
||||||
return getTraceConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public rotateDebugToken(): { path: string; token: string } {
|
|
||||||
const rotated = rotateDebugToken(this.storagePaths.baseDir);
|
|
||||||
this.audit("WARN", "Debug-Token rotiert", { path: rotated.path });
|
|
||||||
return rotated;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
|
||||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
|
|
||||||
logAuditEvent(level, message, fields);
|
|
||||||
logTraceEvent(level, "audit", message, fields);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
|
|
||||||
const next = setTraceEnabled(enabled, note, durationMs);
|
|
||||||
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carry the live, runtime-maintained usage/status counters onto a settings
|
|
||||||
// object about to be applied, so they are never rolled back to a stale snapshot.
|
|
||||||
// All-time totals take the max; daily/total usage and account statuses are taken
|
|
||||||
// live; per-key Debrid-Link usage is filtered to keys that still exist.
|
|
||||||
private overlayLiveUsageCounters(target: AppSettings): void {
|
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
target.totalDownloadedAllTime = Math.max(target.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
|
||||||
target.totalCompletedFilesAllTime = Math.max(target.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
|
|
||||||
target.totalRuntimeAllTimeMs = Math.max(target.totalRuntimeAllTimeMs || 0, this.manager.getLiveTotalRuntimeMs());
|
|
||||||
target.providerDailyUsageDay = liveSettings.providerDailyUsageDay;
|
|
||||||
target.providerDailyUsageBytes = { ...(liveSettings.providerDailyUsageBytes || {}) };
|
|
||||||
target.providerTotalUsageBytes = { ...(liveSettings.providerTotalUsageBytes || {}) };
|
|
||||||
target.debridLinkApiKeyDailyUsageBytes = Object.fromEntries(
|
|
||||||
Object.entries(liveSettings.debridLinkApiKeyDailyUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
|
||||||
);
|
|
||||||
target.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
|
|
||||||
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(target.debridLinkApiKeys).includes(keyId))
|
|
||||||
);
|
|
||||||
target.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
public updateSettings(partial: Partial<AppSettings>): AppSettings {
|
||||||
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
const sanitizedPatch = sanitizeSettingsPatch(partial);
|
||||||
const previousSettings = this.settings;
|
const previousSettings = this.settings;
|
||||||
@ -336,18 +173,12 @@ export class AppController {
|
|||||||
return previousSettings;
|
return previousSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.overlayLiveUsageCounters(nextSettings);
|
// Preserve the live totalDownloadedAllTime from the download manager
|
||||||
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
|
const liveSettings = this.manager.getSettings();
|
||||||
|
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
|
||||||
this.settings = nextSettings;
|
this.settings = nextSettings;
|
||||||
if (retentionChanged) {
|
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
|
||||||
}
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
this.audit("INFO", "Einstellungen aktualisiert", {
|
|
||||||
changedKeys: Object.keys(sanitizedPatch),
|
|
||||||
accountChanges: diffAccountSummary(previousSettings, this.settings)
|
|
||||||
});
|
|
||||||
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
if (previousSettings.rememberToken && !this.settings.rememberToken) {
|
||||||
void this.realDebridWebFallback.clearSessions().catch((error) => {
|
void this.realDebridWebFallback.clearSessions().catch((error) => {
|
||||||
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
|
||||||
@ -362,49 +193,16 @@ export class AppController {
|
|||||||
return this.settings;
|
return this.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
|
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
const nextSettings = normalizeSettings({
|
|
||||||
...liveSettings,
|
|
||||||
...resetProviderDailyUsage(liveSettings, provider)
|
|
||||||
});
|
|
||||||
this.settings = nextSettings;
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
this.manager.setSettings(this.settings);
|
|
||||||
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
|
|
||||||
const liveSettings = this.manager.getSettings();
|
|
||||||
const nextSettings = normalizeSettings({
|
|
||||||
...liveSettings,
|
|
||||||
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
|
|
||||||
});
|
|
||||||
this.settings = nextSettings;
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
this.manager.setSettings(this.settings);
|
|
||||||
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
|
|
||||||
return this.settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async openRealDebridLoginWindow(): Promise<void> {
|
public async openRealDebridLoginWindow(): Promise<void> {
|
||||||
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
|
|
||||||
await this.realDebridWebFallback.openLoginWindow();
|
await this.realDebridWebFallback.openLoginWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async openAllDebridLoginWindow(): Promise<void> {
|
public async openAllDebridLoginWindow(): Promise<void> {
|
||||||
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
|
|
||||||
await this.allDebridWebFallback.openLoginWindow();
|
await this.allDebridWebFallback.openLoginWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async importBestDebridCookies(filePath: string): Promise<number> {
|
public async importBestDebridCookies(filePath: string): Promise<number> {
|
||||||
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
|
return this.bestDebridWebFallback.importCookiesFromFile(filePath);
|
||||||
this.audit("INFO", "BestDebrid Cookies importiert", {
|
|
||||||
filePath,
|
|
||||||
imported
|
|
||||||
});
|
|
||||||
return imported;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
|
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
|
||||||
@ -418,31 +216,6 @@ export class AppController {
|
|||||||
return fetchAllDebridHostInfo(token, host);
|
return fetchAllDebridHostInfo(token, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDebridLinkHostLimits(host = "rapidgator") {
|
|
||||||
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|
||||||
const statuses = await checkAllDebridAccounts(this.settings);
|
|
||||||
this.manager.applyDebridAccountStatuses(statuses);
|
|
||||||
this.audit("INFO", "Debrid-Accounts geprueft", {
|
|
||||||
total: statuses.length,
|
|
||||||
valid: statuses.filter((s) => s.valid).length,
|
|
||||||
premium: statuses.filter((s) => s.isPremium).length
|
|
||||||
});
|
|
||||||
return statuses;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
|
|
||||||
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
|
|
||||||
if (!entry) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const status = await checkMegaDebridAccount(entry);
|
|
||||||
this.manager.applyDebridAccountStatuses([status]);
|
|
||||||
this.audit("INFO", "Mega-Debrid-Account einzeln geprueft", { valid: status.valid, premium: status.isPremium });
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
public async checkUpdates(): Promise<UpdateCheckResult> {
|
public async checkUpdates(): Promise<UpdateCheckResult> {
|
||||||
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
const result = await checkGitHubUpdate(this.settings.updateRepo);
|
||||||
if (!result.error) {
|
if (!result.error) {
|
||||||
@ -453,10 +226,11 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
|
||||||
|
// Stop active downloads before installing. Extractions may continue briefly
|
||||||
|
// until prepareForShutdown() is called during app quit.
|
||||||
if (this.manager.isSessionRunning()) {
|
if (this.manager.isSessionRunning()) {
|
||||||
this.manager.stop({ parkForRestart: true });
|
this.manager.stop();
|
||||||
}
|
}
|
||||||
this.manager.persistNowSync();
|
|
||||||
|
|
||||||
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
|
||||||
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
|
||||||
@ -473,17 +247,9 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
|
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
|
||||||
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
|
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
|
|
||||||
hasPackageName: Boolean(payload.packageName)
|
|
||||||
});
|
|
||||||
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
|
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
|
||||||
}
|
}
|
||||||
const result = this.manager.addPackages(parsed);
|
const result = this.manager.addPackages(parsed);
|
||||||
this.audit("INFO", "Links hinzugefügt", {
|
|
||||||
addedPackages: result.addedPackages,
|
|
||||||
addedLinks: result.addedLinks,
|
|
||||||
requestedPackages: parsed.length
|
|
||||||
});
|
|
||||||
return { ...result, invalidCount: 0 };
|
return { ...result, invalidCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -495,11 +261,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
|
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
|
||||||
}));
|
}));
|
||||||
const result = this.manager.addPackages(merged);
|
const result = this.manager.addPackages(merged);
|
||||||
this.audit("INFO", "Container importiert", {
|
|
||||||
files: filePaths.length,
|
|
||||||
addedPackages: result.addedPackages,
|
|
||||||
addedLinks: result.addedLinks
|
|
||||||
});
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -512,274 +273,134 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public clearAll(): void {
|
public clearAll(): void {
|
||||||
this.audit("WARN", "Queue komplett geleert");
|
|
||||||
this.manager.clearAll();
|
this.manager.clearAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async start(): Promise<void> {
|
public async start(): Promise<void> {
|
||||||
this.audit("INFO", "Session-Start ausgelöst");
|
|
||||||
await this.manager.start();
|
await this.manager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async startPackages(packageIds: string[]): Promise<void> {
|
public async startPackages(packageIds: string[]): Promise<void> {
|
||||||
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
|
|
||||||
await this.manager.startPackages(packageIds);
|
await this.manager.startPackages(packageIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async startItems(itemIds: string[]): Promise<void> {
|
public async startItems(itemIds: string[]): Promise<void> {
|
||||||
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
|
|
||||||
await this.manager.startItems(itemIds);
|
await this.manager.startItems(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop(): void {
|
public stop(): void {
|
||||||
this.audit("INFO", "Session-Stopp ausgelöst");
|
|
||||||
this.manager.stop();
|
this.manager.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
public togglePause(): boolean {
|
public togglePause(): boolean {
|
||||||
const paused = this.manager.togglePause();
|
return this.manager.togglePause();
|
||||||
this.audit("INFO", "Pause umgeschaltet", { paused });
|
|
||||||
return paused;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public retryExtraction(packageId: string): void {
|
public retryExtraction(packageId: string): void {
|
||||||
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
|
|
||||||
this.manager.retryExtraction(packageId);
|
this.manager.retryExtraction(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public extractNow(packageId: string): void {
|
public extractNow(packageId: string): void {
|
||||||
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
|
|
||||||
this.manager.extractNow(packageId);
|
this.manager.extractNow(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetPackage(packageId: string): void {
|
public resetPackage(packageId: string): void {
|
||||||
this.audit("INFO", "Paket zurückgesetzt", { packageId });
|
|
||||||
this.manager.resetPackage(packageId);
|
this.manager.resetPackage(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public cancelPackage(packageId: string): void {
|
public cancelPackage(packageId: string): void {
|
||||||
this.audit("WARN", "Paket abgebrochen", { packageId });
|
|
||||||
this.manager.cancelPackage(packageId);
|
this.manager.cancelPackage(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public renamePackage(packageId: string, newName: string): void {
|
public renamePackage(packageId: string, newName: string): void {
|
||||||
this.audit("INFO", "Paket umbenannt", { packageId, newName });
|
|
||||||
this.manager.renamePackage(packageId, newName);
|
this.manager.renamePackage(packageId, newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public reorderPackages(packageIds: string[]): void {
|
public reorderPackages(packageIds: string[]): void {
|
||||||
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
|
|
||||||
this.manager.reorderPackages(packageIds);
|
this.manager.reorderPackages(packageIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeItem(itemId: string): void {
|
public removeItem(itemId: string): void {
|
||||||
this.audit("WARN", "Item entfernt", { itemId });
|
|
||||||
this.manager.removeItem(itemId);
|
this.manager.removeItem(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public togglePackage(packageId: string): void {
|
public togglePackage(packageId: string): void {
|
||||||
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
|
|
||||||
this.manager.togglePackage(packageId);
|
this.manager.togglePackage(packageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public exportPackageSelection(packageIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
|
||||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), packageIds, []);
|
|
||||||
this.audit("INFO", "Paket-Auswahl exportiert", {
|
|
||||||
packageCount: selection.packageCount,
|
|
||||||
linkCount: selection.linkCount,
|
|
||||||
packageIds
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
text: serializeLinkExportText(selection.packages),
|
|
||||||
defaultFileName: selection.defaultFileName,
|
|
||||||
packageCount: selection.packageCount,
|
|
||||||
linkCount: selection.linkCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public exportItemSelection(itemIds: string[]): { text: string; defaultFileName: string; packageCount: number; linkCount: number } {
|
|
||||||
const selection = buildLinkExportSelection(this.manager.getSnapshot(), [], itemIds);
|
|
||||||
this.audit("INFO", "Item-Auswahl exportiert", {
|
|
||||||
packageCount: selection.packageCount,
|
|
||||||
linkCount: selection.linkCount,
|
|
||||||
itemIds
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
text: serializeLinkExportText(selection.packages),
|
|
||||||
defaultFileName: selection.defaultFileName,
|
|
||||||
packageCount: selection.packageCount,
|
|
||||||
linkCount: selection.linkCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public exportQueue(): string {
|
public exportQueue(): string {
|
||||||
return this.manager.exportQueue();
|
return this.manager.exportQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
public importQueue(json: string): { addedPackages: number; addedLinks: number } {
|
||||||
const result = this.manager.importQueue(json);
|
return this.manager.importQueue(json);
|
||||||
this.audit("INFO", "Import-Datei verarbeitet", result);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionStats(): SessionStats {
|
public getSessionStats(): SessionStats {
|
||||||
return this.manager.getSessionStats();
|
return this.manager.getSessionStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetSessionStats(): void {
|
public exportBackup(): string {
|
||||||
this.audit("INFO", "Session-Statistik zurückgesetzt");
|
const settings = { ...this.settings };
|
||||||
this.manager.resetSessionStats();
|
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"];
|
||||||
}
|
for (const key of SENSITIVE_KEYS) {
|
||||||
|
const val = settings[key];
|
||||||
public resetDownloadStats(): void {
|
if (typeof val === "string" && val.length > 0) {
|
||||||
this.manager.resetDownloadStats();
|
(settings as Record<string, unknown>)[key] = `***${val.slice(-4)}`;
|
||||||
this.settings = this.manager.getSettings();
|
|
||||||
this.audit("INFO", "Download-Statistik zurückgesetzt");
|
|
||||||
}
|
|
||||||
|
|
||||||
public exportBackup(): Buffer {
|
|
||||||
const includeDownloads = Boolean(this.settings.backupIncludeDownloads);
|
|
||||||
const payloadObj = buildBackupPayload({
|
|
||||||
settings: { ...this.settings },
|
|
||||||
appVersion: APP_VERSION,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
session: this.manager.getSession(),
|
|
||||||
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)
|
|
||||||
});
|
|
||||||
this.audit("INFO", "Backup exportiert", {
|
|
||||||
kind: payloadObj.kind,
|
|
||||||
historyEntries: payloadObj.history ? payloadObj.history.length : 0,
|
|
||||||
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0,
|
|
||||||
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
|
|
||||||
});
|
|
||||||
return encryptBackup(JSON.stringify(payloadObj));
|
|
||||||
}
|
|
||||||
|
|
||||||
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
|
|
||||||
this.audit("INFO", "Support-Bundle exportiert");
|
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle erstellt", {
|
|
||||||
packageCount: Object.keys(this.manager.getSnapshot().session.packages).length,
|
|
||||||
itemCount: Object.keys(this.manager.getSnapshot().session.items).length
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
buffer: buildSupportBundle(this.manager, this.storagePaths.baseDir, { hostDiagnosticsMode: "cached" }),
|
|
||||||
defaultFileName: getSupportBundleDefaultFileName()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSupportBundleDefaultFileName(): string {
|
|
||||||
return getSupportBundleDefaultFileName();
|
|
||||||
}
|
|
||||||
|
|
||||||
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } {
|
|
||||||
let parsed: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
const json = decryptBackup(data);
|
|
||||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const json = data.toString("utf8");
|
|
||||||
parsed = JSON.parse(json) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const plan = planBackupImport(parsed);
|
const session = this.manager.getSession();
|
||||||
if (!plan.valid) {
|
return JSON.stringify({ version: 1, settings, session }, null, 2);
|
||||||
return { restored: false, relaunch: false, message: plan.message };
|
}
|
||||||
}
|
|
||||||
const hasSession = plan.restoreDownloads;
|
|
||||||
|
|
||||||
|
public importBackup(json: string): { restored: boolean; message: string } {
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return { restored: false, message: "Ungültiges JSON" };
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
|
||||||
|
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
|
||||||
|
}
|
||||||
const importedSettings = parsed.settings as AppSettings;
|
const importedSettings = parsed.settings as AppSettings;
|
||||||
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
|
const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey"];
|
||||||
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
|
|
||||||
const SENSITIVE_KEYS: (keyof AppSettings)[] = [
|
|
||||||
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
|
|
||||||
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
|
|
||||||
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
|
|
||||||
];
|
|
||||||
for (const key of SENSITIVE_KEYS) {
|
for (const key of SENSITIVE_KEYS) {
|
||||||
const val = importedSettingsRecord[key];
|
const val = (importedSettings as Record<string, unknown>)[key];
|
||||||
if (typeof val === "string" && val.startsWith("***")) {
|
if (typeof val === "string" && val.startsWith("***")) {
|
||||||
importedSettingsRecord[key] = currentSettingsRecord[key];
|
(importedSettings as Record<string, unknown>)[key] = (this.settings as Record<string, unknown>)[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const restoredSettings = normalizeSettings(importedSettings);
|
const restoredSettings = normalizeSettings(importedSettings);
|
||||||
|
|
||||||
// Settings-only backup: keep the running queue AND the live counters untouched.
|
|
||||||
// Overlay the live usage/status counters so they don't roll back to the backup's
|
|
||||||
// (older) snapshot (BUG I), and suppress the retroactive cleanup sweep so the
|
|
||||||
// backup's cleanup policy can't purge the live completed queue here (BUG B) — the
|
|
||||||
// policy still governs FUTURE completions through the normal path. Do NOT stop the
|
|
||||||
// manager, wipe the session, block persistence or relaunch.
|
|
||||||
if (!hasSession) {
|
|
||||||
this.overlayLiveUsageCounters(restoredSettings);
|
|
||||||
this.settings = restoredSettings;
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
this.manager.setSettings(this.settings, { suppressRetroactiveCleanup: true });
|
|
||||||
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
|
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
restored: true,
|
|
||||||
relaunch: false,
|
|
||||||
message: "Einstellungen wiederhergestellt"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.settings = restoredSettings;
|
this.settings = restoredSettings;
|
||||||
saveSettings(this.storagePaths, this.settings);
|
saveSettings(this.storagePaths, this.settings);
|
||||||
this.manager.setSettings(this.settings);
|
this.manager.setSettings(this.settings);
|
||||||
|
// Full stop including extraction abort — the old session is being replaced,
|
||||||
|
// so no extraction tasks from it should keep running.
|
||||||
this.manager.stop();
|
this.manager.stop();
|
||||||
this.manager.abortAllPostProcessing();
|
this.manager.abortAllPostProcessing();
|
||||||
|
// Cancel any deferred persist timer and queued async writes so the old
|
||||||
|
// in-memory session does not overwrite the restored session file on disk.
|
||||||
this.manager.clearPersistTimer();
|
this.manager.clearPersistTimer();
|
||||||
cancelPendingAsyncSaves();
|
cancelPendingAsyncSaves();
|
||||||
|
|
||||||
const restoredSession = normalizeLoadedSessionTransientFields(
|
const restoredSession = normalizeLoadedSessionTransientFields(
|
||||||
normalizeLoadedSession(parsed.session)
|
normalizeLoadedSession(parsed.session)
|
||||||
);
|
);
|
||||||
saveSession(this.storagePaths, restoredSession);
|
saveSession(this.storagePaths, restoredSession);
|
||||||
|
// Prevent prepareForShutdown from overwriting the restored session file
|
||||||
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
|
// with the old in-memory session when the app quits after backup restore.
|
||||||
const normalizedHistory = (parsed.history as unknown[])
|
|
||||||
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
|
|
||||||
.filter((entry): entry is HistoryEntry => entry !== null);
|
|
||||||
if (normalizedHistory.length > 0) {
|
|
||||||
saveHistory(this.storagePaths, normalizedHistory);
|
|
||||||
logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
|
||||||
|
|
||||||
this.manager.skipShutdownPersist = true;
|
this.manager.skipShutdownPersist = true;
|
||||||
|
// Block all persistence (including persistSoon from any IPC operations
|
||||||
|
// the user might trigger before restarting) to protect the restored backup.
|
||||||
this.manager.blockAllPersistence = true;
|
this.manager.blockAllPersistence = true;
|
||||||
logger.info("Backup wiederhergestellt — App startet automatisch neu");
|
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
|
||||||
this.audit("WARN", "Backup importiert", {
|
|
||||||
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
|
|
||||||
accountSummary: buildAccountSummary(this.settings)
|
|
||||||
});
|
|
||||||
return { restored: true, relaunch: true, message: "Backup wiederhergestellt – App startet automatisch neu…" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSessionLogPath(): string | null {
|
public getSessionLogPath(): string | null {
|
||||||
return getSessionLogPath();
|
return getSessionLogPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPackageLogPath(packageId: string): string | null {
|
|
||||||
return this.manager.getPackageLogPath(packageId) || getPackageLogPath(packageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getItemLogPath(itemId: string): string | null {
|
|
||||||
return this.manager.getItemLogPath(itemId) || getItemLogPath(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public shutdown(): void {
|
public shutdown(): void {
|
||||||
if (this.runtimeStatsTimer) {
|
|
||||||
clearInterval(this.runtimeStatsTimer);
|
|
||||||
this.runtimeStatsTimer = null;
|
|
||||||
}
|
|
||||||
stopDebugServer();
|
stopDebugServer();
|
||||||
abortActiveUpdateDownload();
|
abortActiveUpdateDownload();
|
||||||
this.manager.prepareForShutdown();
|
this.manager.prepareForShutdown();
|
||||||
@ -788,57 +409,34 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
|
|||||||
this.allDebridWebFallback.dispose();
|
this.allDebridWebFallback.dispose();
|
||||||
this.bestDebridWebFallback.dispose();
|
this.bestDebridWebFallback.dispose();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
shutdownPackageLogs();
|
|
||||||
shutdownItemLogs();
|
|
||||||
shutdownRenameLog();
|
|
||||||
shutdownDesktopRenameLog();
|
|
||||||
this.audit("INFO", "App beendet");
|
|
||||||
shutdownTraceLog();
|
|
||||||
shutdownAccountRotationLog();
|
|
||||||
shutdownAuditLog();
|
|
||||||
if (this.settings.historyRetentionMode === "session") {
|
|
||||||
clearHistory(this.storagePaths);
|
|
||||||
}
|
|
||||||
logger.info("App beendet");
|
logger.info("App beendet");
|
||||||
}
|
}
|
||||||
|
|
||||||
public getHistory(): HistoryEntry[] {
|
public getHistory(): HistoryEntry[] {
|
||||||
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
|
return loadHistory(this.storagePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
public clearHistory(): void {
|
public clearHistory(): void {
|
||||||
this.audit("WARN", "Verlauf geleert");
|
|
||||||
clearHistory(this.storagePaths);
|
clearHistory(this.storagePaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
public setPackagePriority(packageId: string, priority: PackagePriority): void {
|
||||||
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
|
|
||||||
this.manager.setPackagePriority(packageId, priority);
|
this.manager.setPackagePriority(packageId, priority);
|
||||||
}
|
}
|
||||||
|
|
||||||
public skipItems(itemIds: string[]): void {
|
public skipItems(itemIds: string[]): void {
|
||||||
this.audit("INFO", "Items übersprungen", { itemIds });
|
|
||||||
this.manager.skipItems(itemIds);
|
this.manager.skipItems(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetItems(itemIds: string[]): void {
|
public resetItems(itemIds: string[]): void {
|
||||||
this.audit("INFO", "Items zurückgesetzt", { itemIds });
|
|
||||||
this.manager.resetItems(itemIds);
|
this.manager.resetItems(itemIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeHistoryEntry(entryId: string): void {
|
public removeHistoryEntry(entryId: string): void {
|
||||||
this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
|
|
||||||
removeHistoryEntry(this.storagePaths, entryId);
|
removeHistoryEntry(this.storagePaths, entryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addToHistory(entry: HistoryEntry): void {
|
public addToHistory(entry: HistoryEntry): void {
|
||||||
this.audit("INFO", "Verlaufseintrag hinzugefügt", {
|
|
||||||
id: entry.id,
|
|
||||||
name: entry.name,
|
|
||||||
status: entry.status,
|
|
||||||
provider: entry.provider,
|
|
||||||
fileCount: entry.fileCount
|
|
||||||
});
|
|
||||||
addHistoryEntry(this.storagePaths, entry);
|
addHistoryEntry(this.storagePaths, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
type AuditLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
|
||||||
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
|
|
||||||
|
|
||||||
let auditLogPath: string | null = null;
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
fs.renameSync(filePath, backup);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(backup);
|
|
||||||
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initAuditLog(baseDir: string): void {
|
|
||||||
auditLogPath = path.join(baseDir, "audit.log");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
|
|
||||||
cleanupOldBackup(auditLogPath);
|
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
rotateIfNeeded(auditLogPath);
|
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
auditLogPath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
|
|
||||||
if (!auditLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
rotateIfNeeded(auditLogPath);
|
|
||||||
if (!fs.existsSync(auditLogPath)) {
|
|
||||||
fs.writeFileSync(auditLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(
|
|
||||||
auditLogPath,
|
|
||||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuditLogPath(): string | null {
|
|
||||||
if (!auditLogPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(auditLogPath) ? auditLogPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownAuditLog(): void {
|
|
||||||
if (!auditLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
auditLogPath = null;
|
|
||||||
}
|
|
||||||
@ -1,39 +1,66 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
export const SENSITIVE_KEYS = [
|
||||||
const ALGORITHM = "aes-256-gcm";
|
"token",
|
||||||
const IV_LENGTH = 12;
|
"megaLogin",
|
||||||
const AUTH_TAG_LENGTH = 16;
|
"megaPassword",
|
||||||
const MAGIC = Buffer.from("MDD1");
|
"bestToken",
|
||||||
|
"allDebridToken",
|
||||||
|
"archivePasswordList"
|
||||||
|
] as const;
|
||||||
|
|
||||||
function deriveKey(): Buffer {
|
export type SensitiveKey = (typeof SENSITIVE_KEYS)[number];
|
||||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
|
||||||
|
export interface EncryptedCredentials {
|
||||||
|
salt: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function encryptBackup(plaintext: string): Buffer {
|
const PBKDF2_ITERATIONS = 100_000;
|
||||||
const key = deriveKey();
|
const KEY_LENGTH = 32; // 256 bit
|
||||||
|
const IV_LENGTH = 12; // 96 bit for GCM
|
||||||
|
const SALT_LENGTH = 16;
|
||||||
|
|
||||||
|
function deriveKey(username: string, salt: Buffer): Buffer {
|
||||||
|
return crypto.pbkdf2Sync(username, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptCredentials(
|
||||||
|
fields: Record<string, string>,
|
||||||
|
username: string
|
||||||
|
): EncryptedCredentials {
|
||||||
|
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
const key = deriveKey(username, salt);
|
||||||
|
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
|
||||||
|
const plaintext = JSON.stringify(fields);
|
||||||
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 tag = cipher.getAuthTag();
|
||||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
|
||||||
|
return {
|
||||||
|
salt: salt.toString("hex"),
|
||||||
|
iv: iv.toString("hex"),
|
||||||
|
tag: tag.toString("hex"),
|
||||||
|
data: encrypted.toString("hex")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decryptBackup(data: Buffer): string {
|
export function decryptCredentials(
|
||||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
encrypted: EncryptedCredentials,
|
||||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
username: string
|
||||||
}
|
): Record<string, string> {
|
||||||
const magic = data.subarray(0, MAGIC.length);
|
const salt = Buffer.from(encrypted.salt, "hex");
|
||||||
if (!magic.equals(MAGIC)) {
|
const iv = Buffer.from(encrypted.iv, "hex");
|
||||||
throw new Error("Keine gültige MDD-Backup-Datei (falsche Signatur)");
|
const tag = Buffer.from(encrypted.tag, "hex");
|
||||||
}
|
const data = Buffer.from(encrypted.data, "hex");
|
||||||
const iv = data.subarray(MAGIC.length, MAGIC.length + IV_LENGTH);
|
const key = deriveKey(username, salt);
|
||||||
const authTag = data.subarray(MAGIC.length + IV_LENGTH, MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
||||||
const ciphertext = data.subarray(MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
||||||
|
|
||||||
const key = deriveKey();
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
|
||||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
decipher.setAuthTag(tag);
|
||||||
decipher.setAuthTag(authTag);
|
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
||||||
return decrypted.toString("utf8");
|
return JSON.parse(decrypted.toString("utf8")) as Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
|
||||||
|
|
||||||
export type BackupKind = "full" | "settings-only";
|
|
||||||
|
|
||||||
export interface BackupPayload {
|
|
||||||
version: 2;
|
|
||||||
kind: BackupKind;
|
|
||||||
appVersion: string;
|
|
||||||
exportedAt: string;
|
|
||||||
settings: AppSettings;
|
|
||||||
session?: SessionState;
|
|
||||||
history?: HistoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BuildBackupInput {
|
|
||||||
settings: AppSettings;
|
|
||||||
appVersion: string;
|
|
||||||
exportedAt: string;
|
|
||||||
/** Only bundled when includeDownloads is true. */
|
|
||||||
session: SessionState;
|
|
||||||
history: HistoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
|
|
||||||
* payload contains ONLY settings — no session, no history. The download list is
|
|
||||||
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
|
|
||||||
* marker makes the import side unambiguous and survives hand-edited files.
|
|
||||||
*/
|
|
||||||
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
|
||||||
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
|
|
||||||
const base: BackupPayload = {
|
|
||||||
version: 2,
|
|
||||||
kind: includeDownloads ? "full" : "settings-only",
|
|
||||||
appVersion: input.appVersion,
|
|
||||||
exportedAt: input.exportedAt,
|
|
||||||
settings: input.settings
|
|
||||||
};
|
|
||||||
if (includeDownloads) {
|
|
||||||
base.session = input.session;
|
|
||||||
base.history = input.history;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportPlan {
|
|
||||||
valid: boolean;
|
|
||||||
/** Restore the download list (session + history) and relaunch. */
|
|
||||||
restoreDownloads: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide how to apply an imported backup based on what the FILE physically
|
|
||||||
* contains — NOT the local toggle. A backup without a session restores settings
|
|
||||||
* only (no queue wipe, no relaunch); a full backup (with session) restores the
|
|
||||||
* queue too. This way an old full backup still restores fully even if the local
|
|
||||||
* toggle is currently off, and a settings-only backup never disturbs a running
|
|
||||||
* queue.
|
|
||||||
*/
|
|
||||||
export function planBackupImport(parsed: unknown): ImportPlan {
|
|
||||||
if (!parsed || typeof parsed !== "object") {
|
|
||||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
|
||||||
}
|
|
||||||
const record = parsed as Record<string, unknown>;
|
|
||||||
if (!record.settings || typeof record.settings !== "object") {
|
|
||||||
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
|
|
||||||
}
|
|
||||||
const hasSession = Boolean(record.session) && typeof record.session === "object";
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
restoreDownloads: hasSession,
|
|
||||||
message: hasSession
|
|
||||||
? "Backup wiederhergestellt – App startet automatisch neu…"
|
|
||||||
: "Einstellungen wiederhergestellt"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -212,15 +212,18 @@ export class BestDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
// nothing to clean up
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPartition(): string {
|
private getPartition(): string {
|
||||||
@ -341,6 +344,7 @@ export class BestDebridWebFallback {
|
|||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore cache clear failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
|||||||
fs.rmSync(full, { force: true });
|
fs.rmSync(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,10 +47,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupCancelledPackageArtifactsAsync(
|
export async function cleanupCancelledPackageArtifactsAsync(packageDir: string): Promise<number> {
|
||||||
packageDir: string,
|
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
|
||||||
): Promise<number> {
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(packageDir, fs.constants.F_OK);
|
await fs.promises.access(packageDir, fs.constants.F_OK);
|
||||||
} catch {
|
} catch {
|
||||||
@ -60,9 +58,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
let touched = 0;
|
let touched = 0;
|
||||||
const stack = [packageDir];
|
const stack = [packageDir];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
const current = stack.pop() as string;
|
const current = stack.pop() as string;
|
||||||
let entries: fs.Dirent[] = [];
|
let entries: fs.Dirent[] = [];
|
||||||
try {
|
try {
|
||||||
@ -72,9 +67,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (options.shouldAbort?.()) {
|
|
||||||
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);
|
||||||
@ -83,6 +75,7 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,10 +88,7 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeDownloadLinkArtifacts(
|
export async function removeDownloadLinkArtifacts(extractDir: string): Promise<number> {
|
||||||
extractDir: string,
|
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
|
||||||
): Promise<number> {
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(extractDir);
|
await fs.promises.access(extractDir);
|
||||||
} catch {
|
} catch {
|
||||||
@ -107,16 +97,10 @@ export async function removeDownloadLinkArtifacts(
|
|||||||
let removed = 0;
|
let removed = 0;
|
||||||
const stack = [extractDir];
|
const stack = [extractDir];
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
|
||||||
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?.()) {
|
|
||||||
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);
|
||||||
@ -148,6 +132,7 @@ export async function removeDownloadLinkArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,10 +140,7 @@ export async function removeDownloadLinkArtifacts(
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeSampleArtifacts(
|
export async function removeSampleArtifacts(extractDir: string): Promise<{ files: number; dirs: number }> {
|
||||||
extractDir: string,
|
|
||||||
options: { shouldAbort?: () => boolean } = {}
|
|
||||||
): Promise<{ files: number; dirs: number }> {
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(extractDir);
|
await fs.promises.access(extractDir);
|
||||||
} catch {
|
} catch {
|
||||||
@ -202,16 +184,10 @@ export async function removeSampleArtifacts(
|
|||||||
};
|
};
|
||||||
|
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
if (options.shouldAbort?.()) {
|
|
||||||
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?.()) {
|
|
||||||
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();
|
||||||
@ -237,6 +213,7 @@ export async function removeSampleArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removedFiles += 1;
|
removedFiles += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,9 +221,6 @@ export async function removeSampleArtifacts(
|
|||||||
|
|
||||||
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?.()) {
|
|
||||||
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()) {
|
||||||
@ -259,6 +233,7 @@ export async function removeSampleArtifacts(
|
|||||||
removedFiles += filesInDir;
|
removedFiles += filesInDir;
|
||||||
removedDirs += 1;
|
removedDirs += 1;
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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 packageJson from "../../package.json";
|
import packageJson from "../../package.json";
|
||||||
|
|
||||||
export const APP_NAME = "Multi Debrid Downloader";
|
export const APP_NAME = "Multi Debrid Downloader";
|
||||||
@ -17,12 +16,11 @@ 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; // 512 KB write buffer (JDownloader: 500 KB)
|
||||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
|
||||||
export const ALLOCATION_UNIT_SIZE = 4096;
|
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
|
||||||
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
|
export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow
|
||||||
export const DISK_BUSY_THRESHOLD_MS = 300;
|
export const DISK_BUSY_THRESHOLD_MS = 300; // Show "Warte auf Festplatte" if writableLength > 0 for this long
|
||||||
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"]);
|
||||||
@ -46,7 +44,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
realDebridUseWebLogin: false,
|
realDebridUseWebLogin: false,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
megaCredentials: "",
|
|
||||||
megaDebridApiEnabled: false,
|
megaDebridApiEnabled: false,
|
||||||
megaDebridWebEnabled: false,
|
megaDebridWebEnabled: false,
|
||||||
megaDebridPreferApi: true,
|
megaDebridPreferApi: true,
|
||||||
@ -58,7 +55,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
ddownloadPassword: "",
|
ddownloadPassword: "",
|
||||||
oneFichierApiKey: "",
|
oneFichierApiKey: "",
|
||||||
debridLinkApiKeys: "",
|
debridLinkApiKeys: "",
|
||||||
debridLinkDisabledKeyIds: [],
|
|
||||||
linkSnappyLogin: "",
|
linkSnappyLogin: "",
|
||||||
linkSnappyPassword: "",
|
linkSnappyPassword: "",
|
||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
@ -72,8 +68,6 @@ export function defaultSettings(): AppSettings {
|
|||||||
packageName: "",
|
packageName: "",
|
||||||
autoExtract: true,
|
autoExtract: true,
|
||||||
autoRename4sf4sj: false,
|
autoRename4sf4sj: false,
|
||||||
keepGermanAudioOnly: false,
|
|
||||||
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"),
|
||||||
@ -100,34 +94,15 @@ export function defaultSettings(): AppSettings {
|
|||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
theme: "dark" as const,
|
theme: "dark" as const,
|
||||||
collapseNewPackages: true,
|
collapseNewPackages: true,
|
||||||
historyRetentionMode: "permanent",
|
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
|
||||||
autoSortPackagesByProgress: true,
|
|
||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
hideExtractedItems: true,
|
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
backupIncludeDownloads: false,
|
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalCompletedFilesAllTime: 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: {},
|
|
||||||
providerDailyUsageBytes: {},
|
|
||||||
providerTotalUsageBytes: {},
|
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
|
||||||
debridLinkApiKeyTotalUsageBytes: {},
|
|
||||||
megaDebridDisabledAccountIds: [],
|
|
||||||
megaDebridAccountDailyLimitBytes: {},
|
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
|
||||||
debridAccountStatuses: {},
|
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,11 +113,13 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
|||||||
try {
|
try {
|
||||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
links.push(url);
|
links.push(url);
|
||||||
fileNames.push(sanitizeFilename(fileName));
|
fileNames.push(sanitizeFilename(fileName));
|
||||||
} catch {
|
} catch {
|
||||||
|
// skip broken entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,6 +132,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
|||||||
links.push(url);
|
links.push(url);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// skip broken entries
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2030
src/main/debrid.ts
2030
src/main/debrid.ts
File diff suppressed because it is too large
Load Diff
@ -1,105 +1,15 @@
|
|||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
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 { APP_VERSION } from "./constants";
|
|
||||||
import { getAuditLogPath } from "./audit-log";
|
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
|
||||||
import { logger, getLogFilePath } from "./logger";
|
import { logger, getLogFilePath } from "./logger";
|
||||||
import { getRecentErrors } from "./error-ring";
|
|
||||||
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
|
|
||||||
import { getSessionLogPath } from "./session-log";
|
|
||||||
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
|
|
||||||
import { getRenameLogPath } from "./rename-log";
|
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
|
||||||
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
|
|
||||||
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
|
||||||
import type { DownloadManager } from "./download-manager";
|
import type { DownloadManager } from "./download-manager";
|
||||||
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 9868;
|
const DEFAULT_PORT = 9868;
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
|
||||||
const MAX_LOG_LINES = 10000;
|
const MAX_LOG_LINES = 10000;
|
||||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
|
||||||
|
|
||||||
type DebugEndpointDescriptor = {
|
|
||||||
method: "GET";
|
|
||||||
path: string;
|
|
||||||
queryExample?: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|
||||||
{ method: "GET", path: "/health", description: "Basic health, uptime, and memory information." },
|
|
||||||
{ method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." },
|
|
||||||
{ method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." },
|
|
||||||
{ method: "GET", path: "/self-check", description: "Extended support self-check with disk space, log sizes, and support bundle estimate." },
|
|
||||||
{ method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." },
|
|
||||||
{ method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." },
|
|
||||||
{ method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
|
|
||||||
{ method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." },
|
|
||||||
{ method: "GET", path: "/logs/rename", queryExample: "lines=100&grep=keyword", description: "Reads the dedicated rename and MKV move log." },
|
|
||||||
{ method: "GET", path: "/logs/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
|
|
||||||
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
|
|
||||||
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
|
|
||||||
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
|
|
||||||
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
|
||||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
|
||||||
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
|
|
||||||
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
|
|
||||||
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
|
|
||||||
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
|
|
||||||
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
|
|
||||||
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
|
|
||||||
{ method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." },
|
|
||||||
{ method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." },
|
|
||||||
{ method: "GET", path: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted state." },
|
|
||||||
{ method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." }
|
|
||||||
];
|
|
||||||
|
|
||||||
let server: http.Server | null = null;
|
let server: http.Server | null = null;
|
||||||
let manager: DownloadManager | null = null;
|
let manager: DownloadManager | null = null;
|
||||||
let authToken = "";
|
let authToken = "";
|
||||||
let bindHost = DEFAULT_HOST;
|
|
||||||
let bindPort = DEFAULT_PORT;
|
|
||||||
let runtimeBaseDir = "";
|
|
||||||
|
|
||||||
function getStoragePaths() {
|
|
||||||
return createStoragePaths(runtimeBaseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSupportSettings() {
|
|
||||||
return loadSettings(getStoragePaths());
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSupportHistory() {
|
|
||||||
return loadHistory(getStoragePaths());
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDebugClientIp(req: http.IncomingMessage): string {
|
|
||||||
const forwarded = req.headers["x-forwarded-for"];
|
|
||||||
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
||||||
const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim();
|
|
||||||
if (forwardedIp) {
|
|
||||||
return forwardedIp;
|
|
||||||
}
|
|
||||||
const realIp = String(req.headers["x-real-ip"] || "").trim();
|
|
||||||
if (realIp) {
|
|
||||||
return realIp;
|
|
||||||
}
|
|
||||||
const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim();
|
|
||||||
return remote.replace(/^::ffff:/i, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
|
|
||||||
return path.join(baseDir, AI_MANIFEST_FILE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
|
||||||
return path.join(baseDir, "debug_token.txt");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadToken(baseDir: string): string {
|
function loadToken(baseDir: string): string {
|
||||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||||
@ -118,28 +28,11 @@ function getPort(baseDir: string): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHost(baseDir: string): string {
|
|
||||||
const hostPath = path.join(baseDir, "debug_host.txt");
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(hostPath, "utf8").trim();
|
|
||||||
if (!raw) {
|
|
||||||
return DEFAULT_HOST;
|
|
||||||
}
|
|
||||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return DEFAULT_HOST;
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkAuth(req: http.IncomingMessage): boolean {
|
function checkAuth(req: http.IncomingMessage): boolean {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return false;
|
return false;
|
||||||
@ -162,37 +55,10 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
|
|||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
function binaryResponse(
|
function readLogTail(lines: number): string[] {
|
||||||
res: http.ServerResponse,
|
const logPath = getLogFilePath();
|
||||||
status: number,
|
|
||||||
body: Buffer,
|
|
||||||
contentType: string,
|
|
||||||
fileName?: string
|
|
||||||
): void {
|
|
||||||
res.writeHead(status, {
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Content-Length": String(body.length),
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
|
|
||||||
});
|
|
||||||
res.end(body);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
|
||||||
const parsed = Number(rawValue || String(fallback));
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return Math.max(1, Math.min(Math.floor(parsed), MAX_LOG_LINES));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readLogTailFromFile(filePath: string | null, lines: number): string[] {
|
|
||||||
if (!filePath) {
|
|
||||||
return ["(Log-Datei nicht gefunden)"];
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath, "utf8");
|
const content = fs.readFileSync(logPath, "utf8");
|
||||||
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
|
const allLines = content.split("\n").filter((l) => l.trim().length > 0);
|
||||||
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
|
return allLines.slice(-Math.min(lines, MAX_LOG_LINES));
|
||||||
} catch {
|
} catch {
|
||||||
@ -200,543 +66,42 @@ function readLogTailFromFile(filePath: string | null, lines: number): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterLines(lines: string[], grep: string): string[] {
|
|
||||||
const pattern = String(grep || "").trim().toLowerCase();
|
|
||||||
if (!pattern) {
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
return lines.filter((line) => line.toLowerCase().includes(pattern));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toBooleanQuery(value: string | null): boolean | null {
|
|
||||||
if (value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (/^(1|true|yes|on)$/i.test(value)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (/^(0|false|no|off)$/i.test(value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeRequestUrlForTrace(rawUrl: string): string {
|
|
||||||
try {
|
|
||||||
const url = new URL(rawUrl || "/", "http://localhost");
|
|
||||||
if (url.searchParams.has("token")) {
|
|
||||||
url.searchParams.set("token", "***");
|
|
||||||
}
|
|
||||||
return `${url.pathname}${url.search}`;
|
|
||||||
} catch {
|
|
||||||
return String(rawUrl || "/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
|
|
||||||
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEndpointSummaries(): string[] {
|
|
||||||
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAiManifest(baseDir: string): Record<string, unknown> {
|
|
||||||
const remoteHostHint = bindHost === "0.0.0.0"
|
|
||||||
? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown."
|
|
||||||
: "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall.";
|
|
||||||
|
|
||||||
return {
|
|
||||||
schemaVersion: 1,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
appVersion: APP_VERSION,
|
|
||||||
runtimeBaseDir: baseDir,
|
|
||||||
purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.",
|
|
||||||
quickstart: [
|
|
||||||
"Read debug_token.txt and debug_port.txt from this runtime folder.",
|
|
||||||
"If remote access is needed, ask the user only for the server IP or DNS name.",
|
|
||||||
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
|
|
||||||
"Use /self-check or /debug/setup to quickly verify whether token, host, manifest, trace, disk space, and log sizes are in a good support state.",
|
|
||||||
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /logs/rename, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
|
|
||||||
"If a full handoff is needed, download /support/bundle as a ZIP."
|
|
||||||
],
|
|
||||||
auth: {
|
|
||||||
required: true,
|
|
||||||
methods: [
|
|
||||||
"Authorization: Bearer <token>",
|
|
||||||
"?token=<token>"
|
|
||||||
],
|
|
||||||
tokenFile: path.join(baseDir, "debug_token.txt")
|
|
||||||
},
|
|
||||||
runtimeFiles: {
|
|
||||||
hostFile: path.join(baseDir, "debug_host.txt"),
|
|
||||||
portFile: path.join(baseDir, "debug_port.txt"),
|
|
||||||
tokenFile: path.join(baseDir, "debug_token.txt"),
|
|
||||||
mainLogFile: getLogFilePath(),
|
|
||||||
auditLogFile: getAuditLogPath(),
|
|
||||||
renameLogFile: getRenameLogPath(),
|
|
||||||
traceLogFile: getTraceLogPath(),
|
|
||||||
traceConfigFile: getTraceConfigPath(),
|
|
||||||
sessionLogFile: getSessionLogPath(),
|
|
||||||
packageLogDir: path.join(baseDir, "package-logs"),
|
|
||||||
itemLogDir: path.join(baseDir, "item-logs"),
|
|
||||||
settingsFile: path.join(baseDir, "rd_downloader_config.json"),
|
|
||||||
sessionFile: path.join(baseDir, "rd_session_state.json"),
|
|
||||||
historyFile: path.join(baseDir, "rd_history.json")
|
|
||||||
},
|
|
||||||
debugServer: {
|
|
||||||
enabled: Boolean(authToken),
|
|
||||||
host: bindHost,
|
|
||||||
port: bindPort,
|
|
||||||
localBaseUrl: `http://127.0.0.1:${bindPort}`,
|
|
||||||
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`,
|
|
||||||
remoteHostHint
|
|
||||||
},
|
|
||||||
setupCheckEndpoint: "/debug/setup",
|
|
||||||
selfCheckEndpoint: "/self-check",
|
|
||||||
askUserFor: [
|
|
||||||
"Server IP or DNS name, if remote access is required and not already known."
|
|
||||||
],
|
|
||||||
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
|
|
||||||
...endpoint,
|
|
||||||
summary: formatEndpointSummary(endpoint)
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAiManifest(baseDir: string): void {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8");
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } {
|
|
||||||
const token = crypto.randomBytes(24).toString("hex");
|
|
||||||
const tokenPath = getDebugTokenPath(baseDir);
|
|
||||||
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
|
|
||||||
if (baseDir === runtimeBaseDir) {
|
|
||||||
authToken = token;
|
|
||||||
writeAiManifest(baseDir);
|
|
||||||
}
|
|
||||||
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
|
|
||||||
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
|
|
||||||
return { path: tokenPath, token };
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeItem(item: DownloadItem): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
packageId: item.packageId,
|
|
||||||
fileName: item.fileName,
|
|
||||||
status: item.status,
|
|
||||||
fullStatus: item.fullStatus,
|
|
||||||
provider: item.provider,
|
|
||||||
providerLabel: item.providerLabel || "",
|
|
||||||
progress: item.progressPercent,
|
|
||||||
speedMBs: +(item.speedBps / 1024 / 1024).toFixed(2),
|
|
||||||
downloadedMB: +(item.downloadedBytes / 1024 / 1024).toFixed(1),
|
|
||||||
totalMB: item.totalBytes ? +(item.totalBytes / 1024 / 1024).toFixed(1) : null,
|
|
||||||
retries: item.retries,
|
|
||||||
lastError: item.lastError,
|
|
||||||
targetPath: item.targetPath,
|
|
||||||
updatedAt: item.updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizePackage(snapshot: UiSnapshot, pkg: PackageEntry, includeItems: boolean): Record<string, unknown> {
|
|
||||||
const ids = new Set(pkg.itemIds);
|
|
||||||
const packageItems = Object.values(snapshot.session.items).filter((item) => ids.has(item.id));
|
|
||||||
const byStatus: Record<string, number> = {};
|
|
||||||
for (const item of packageItems) {
|
|
||||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: pkg.id,
|
|
||||||
name: pkg.name,
|
|
||||||
status: pkg.status,
|
|
||||||
enabled: pkg.enabled,
|
|
||||||
cancelled: pkg.cancelled,
|
|
||||||
outputDir: pkg.outputDir,
|
|
||||||
extractDir: pkg.extractDir,
|
|
||||||
postProcessLabel: pkg.postProcessLabel || "",
|
|
||||||
itemCount: pkg.itemIds.length,
|
|
||||||
itemCounts: byStatus,
|
|
||||||
updatedAt: pkg.updatedAt,
|
|
||||||
items: includeItems ? packageItems.map((item) => summarizeItem(item)) : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function findPackage(snapshot: UiSnapshot, query: string): PackageEntry | null {
|
|
||||||
const needle = String(query || "").trim().toLowerCase();
|
|
||||||
if (!needle) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Object.values(snapshot.session.packages).find((pkg) =>
|
|
||||||
pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle)
|
|
||||||
) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findItem(snapshot: UiSnapshot, query: string): DownloadItem | null {
|
|
||||||
const needle = String(query || "").trim().toLowerCase();
|
|
||||||
if (!needle) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Object.values(snapshot.session.items).find((item) =>
|
|
||||||
item.id.toLowerCase() === needle || item.fileName.toLowerCase().includes(needle)
|
|
||||||
) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPackageLogPathForQuery(snapshot: UiSnapshot, query: string): { pkg: PackageEntry | null; logPath: string | null } {
|
|
||||||
const pkg = findPackage(snapshot, query);
|
|
||||||
if (pkg) {
|
|
||||||
const livePath = manager?.getPackageLogPath(pkg.id) || null;
|
|
||||||
return { pkg, logPath: livePath || getPersistedPackageLogPath(pkg.id) };
|
|
||||||
}
|
|
||||||
const directPath = getPersistedPackageLogPath(String(query || "").trim());
|
|
||||||
return { pkg: null, logPath: directPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemLogPathForQuery(snapshot: UiSnapshot, query: string): { item: DownloadItem | null; logPath: string | null } {
|
|
||||||
const item = findItem(snapshot, query);
|
|
||||||
if (item) {
|
|
||||||
const livePath = manager?.getItemLogPath(item.id) || null;
|
|
||||||
return { item, logPath: livePath || getPersistedItemLogPath(item.id) };
|
|
||||||
}
|
|
||||||
const directPath = getPersistedItemLogPath(String(query || "").trim());
|
|
||||||
return { item: null, logPath: directPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
|
||||||
const items = Object.values(snapshot.session.items);
|
|
||||||
const packages = Object.values(snapshot.session.packages);
|
|
||||||
|
|
||||||
const byStatus: Record<string, number> = {};
|
|
||||||
for (const item of items) {
|
|
||||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeItems = items
|
|
||||||
.filter((item) => item.status === "downloading" || item.status === "validating")
|
|
||||||
.map((item) => summarizeItem(item));
|
|
||||||
|
|
||||||
const failedItems = items
|
|
||||||
.filter((item) => item.status === "failed")
|
|
||||||
.map((item) => summarizeItem(item));
|
|
||||||
|
|
||||||
return {
|
|
||||||
running: snapshot.session.running,
|
|
||||||
paused: snapshot.session.paused,
|
|
||||||
speed: snapshot.speedText,
|
|
||||||
eta: snapshot.etaText,
|
|
||||||
itemCounts: byStatus,
|
|
||||||
totalItems: items.length,
|
|
||||||
totalPackages: packages.length,
|
|
||||||
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, false)),
|
|
||||||
activeItems,
|
|
||||||
failedItems: failedItems.length > 0 ? failedItems : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
const url = new URL(req.url || "/", "http://localhost");
|
|
||||||
const pathname = url.pathname;
|
|
||||||
const traceConfig = getTraceConfig();
|
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
|
||||||
logTraceEvent("INFO", "debug-http", "Request", {
|
|
||||||
method: req.method || "GET",
|
|
||||||
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
|
||||||
clientIp: extractDebugClientIp(req)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204, {
|
res.writeHead(204, {
|
||||||
"Access-Control-Allow-Origin": "*",
|
"Access-Control-Allow-Origin": "*",
|
||||||
"Access-Control-Allow-Headers": "Authorization",
|
"Access-Control-Allow-Headers": "Authorization"
|
||||||
"Access-Control-Allow-Methods": "GET,OPTIONS"
|
|
||||||
});
|
});
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkAuth(req)) {
|
if (!checkAuth(req)) {
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
|
||||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
|
||||||
method: req.method || "GET",
|
|
||||||
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
|
||||||
clientIp: extractDebugClientIp(req)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
jsonResponse(res, 401, { error: "Unauthorized" });
|
jsonResponse(res, 401, { error: "Unauthorized" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url || "/", "http://localhost");
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname === "/health") {
|
if (pathname === "/health") {
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
status: "ok",
|
status: "ok",
|
||||||
appVersion: APP_VERSION,
|
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
memoryMB: Math.round(process.memoryUsage().rss / 1024 / 1024)
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/meta") {
|
if (pathname === "/log") {
|
||||||
jsonResponse(res, 200, {
|
const count = Math.min(Number(url.searchParams.get("lines") || "100"), MAX_LOG_LINES);
|
||||||
appVersion: APP_VERSION,
|
|
||||||
runtimeBaseDir,
|
|
||||||
debugServer: {
|
|
||||||
host: bindHost,
|
|
||||||
port: bindPort
|
|
||||||
},
|
|
||||||
supportFiles: {
|
|
||||||
aiManifest: getAiManifestPath(),
|
|
||||||
traceConfig: getTraceConfigPath(),
|
|
||||||
traceLog: getTraceLogPath()
|
|
||||||
},
|
|
||||||
supportChecks: {
|
|
||||||
setup: "/debug/setup",
|
|
||||||
selfCheck: "/self-check"
|
|
||||||
},
|
|
||||||
logPaths: {
|
|
||||||
main: getLogFilePath(),
|
|
||||||
audit: getAuditLogPath(),
|
|
||||||
rename: getRenameLogPath(),
|
|
||||||
session: getSessionLogPath(),
|
|
||||||
trace: getTraceLogPath()
|
|
||||||
},
|
|
||||||
endpoints: getEndpointSummaries()
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/debug/setup" || pathname === "/self-check") {
|
|
||||||
jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/host/diagnostics") {
|
|
||||||
jsonResponse(res, 200, getWindowsHostDiagnostics());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/log" || pathname === "/logs/main") {
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
const grep = url.searchParams.get("grep") || "";
|
||||||
const lines = filterLines(readLogTailFromFile(getLogFilePath(), count), grep);
|
let lines = readLogTail(count);
|
||||||
jsonResponse(res, 200, { lines, count: lines.length });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/errors") {
|
|
||||||
const levelFilter = (url.searchParams.get("level") || "").toUpperCase();
|
|
||||||
const limit = normalizeLinesParam(url.searchParams.get("limit"), 100);
|
|
||||||
let entries = getRecentErrors();
|
|
||||||
if (levelFilter === "ERROR" || levelFilter === "WARN") {
|
|
||||||
entries = entries.filter((entry) => entry.level === levelFilter);
|
|
||||||
}
|
|
||||||
const limited = entries.slice(-limit);
|
|
||||||
jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/audit") {
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const logPath = getAuditLogPath();
|
|
||||||
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
path: logPath,
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/rename") {
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const logPath = getRenameLogPath();
|
|
||||||
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
path: logPath,
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/trace") {
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const logPath = getTraceLogPath();
|
|
||||||
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
path: logPath,
|
|
||||||
configPath: getTraceConfigPath(),
|
|
||||||
config: getTraceConfig(),
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/session") {
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const logPath = getSessionLogPath();
|
|
||||||
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
path: logPath,
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/trace/config") {
|
|
||||||
const patch: Record<string, unknown> = {};
|
|
||||||
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
|
||||||
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
|
|
||||||
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
|
|
||||||
const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests"));
|
|
||||||
if (enabled !== null) {
|
|
||||||
patch.enabled = enabled;
|
|
||||||
}
|
|
||||||
if (includeMainLog !== null) {
|
|
||||||
patch.includeMainLog = includeMainLog;
|
|
||||||
}
|
|
||||||
if (includeAudit !== null) {
|
|
||||||
patch.includeAudit = includeAudit;
|
|
||||||
}
|
|
||||||
if (logDebugRequests !== null) {
|
|
||||||
patch.logDebugRequests = logDebugRequests;
|
|
||||||
}
|
|
||||||
const note = String(url.searchParams.get("note") || "").trim();
|
|
||||||
const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120");
|
|
||||||
const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0
|
|
||||||
? Math.min(Math.floor(durationMinutesRaw), 24 * 60)
|
|
||||||
: 120;
|
|
||||||
let config = getTraceConfig();
|
|
||||||
if (enabled !== null) {
|
|
||||||
config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000);
|
|
||||||
}
|
|
||||||
const configPatch = { ...patch };
|
|
||||||
delete configPatch.enabled;
|
|
||||||
if (Object.keys(configPatch).length > 0) {
|
|
||||||
config = updateTraceConfig(configPatch);
|
|
||||||
}
|
|
||||||
if (Object.keys(patch).length > 0) {
|
|
||||||
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes });
|
|
||||||
}
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
path: getTraceConfigPath(),
|
|
||||||
logPath: getTraceLogPath(),
|
|
||||||
config
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/package") {
|
|
||||||
if (!manager) {
|
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const packageQuery = url.searchParams.get("package") || url.searchParams.get("packageId") || "";
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const resolved = getPackageLogPathForQuery(snapshot, packageQuery);
|
|
||||||
if (!resolved.logPath) {
|
|
||||||
jsonResponse(res, 404, { error: "Package log not found", package: packageQuery });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
package: resolved.pkg ? summarizePackage(snapshot, resolved.pkg, false) : undefined,
|
|
||||||
path: resolved.logPath,
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/logs/item") {
|
|
||||||
if (!manager) {
|
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const itemQuery = url.searchParams.get("item") || url.searchParams.get("itemId") || "";
|
|
||||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const resolved = getItemLogPathForQuery(snapshot, itemQuery);
|
|
||||||
if (!resolved.logPath) {
|
|
||||||
jsonResponse(res, 404, { error: "Item log not found", item: itemQuery });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = filterLines(readLogTailFromFile(resolved.logPath, count), grep);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
item: resolved.item ? summarizeItem(resolved.item) : undefined,
|
|
||||||
path: resolved.logPath,
|
|
||||||
lines,
|
|
||||||
count: lines.length
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/settings") {
|
|
||||||
const settings = readSupportSettings();
|
|
||||||
jsonResponse(res, 200, buildRedactedSettingsPayload(settings));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/accounts") {
|
|
||||||
const settings = readSupportSettings();
|
|
||||||
jsonResponse(res, 200, buildAccountSummary(settings));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/stats") {
|
|
||||||
if (!manager) {
|
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const settings = readSupportSettings();
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
...buildStatsPayload(snapshot),
|
|
||||||
allTime: {
|
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/history") {
|
|
||||||
const entries = readSupportHistory();
|
|
||||||
const limit = normalizeLinesParam(url.searchParams.get("limit"), 50);
|
|
||||||
const statusFilter = String(url.searchParams.get("status") || "").trim().toLowerCase();
|
|
||||||
const grep = String(url.searchParams.get("grep") || "").trim().toLowerCase();
|
|
||||||
let filtered = entries;
|
|
||||||
if (statusFilter) {
|
|
||||||
filtered = filtered.filter((entry) => String(entry.status || "").toLowerCase() === statusFilter);
|
|
||||||
}
|
|
||||||
if (grep) {
|
if (grep) {
|
||||||
filtered = filtered.filter((entry) => JSON.stringify(summarizeHistoryEntry(entry)).toLowerCase().includes(grep));
|
const pattern = grep.toLowerCase();
|
||||||
|
lines = lines.filter((l) => l.toLowerCase().includes(pattern));
|
||||||
}
|
}
|
||||||
const sliced = filtered
|
jsonResponse(res, 200, { lines, count: lines.length });
|
||||||
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
|
|
||||||
.slice(0, limit);
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
count: sliced.length,
|
|
||||||
total: filtered.length,
|
|
||||||
entries: sliced.map((entry) => summarizeHistoryEntry(entry))
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -746,26 +111,53 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
jsonResponse(res, 200, buildStatusPayload(snapshot));
|
const items = Object.values(snapshot.session.items);
|
||||||
return;
|
const packages = Object.values(snapshot.session.packages);
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/packages") {
|
const byStatus: Record<string, number> = {};
|
||||||
if (!manager) {
|
for (const item of items) {
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const packageQuery = url.searchParams.get("package") || "";
|
|
||||||
const includeItems = /^(1|true|yes)$/i.test(String(url.searchParams.get("includeItems") || ""));
|
|
||||||
let packages = Object.values(snapshot.session.packages);
|
|
||||||
if (packageQuery) {
|
|
||||||
const needle = packageQuery.toLowerCase();
|
|
||||||
packages = packages.filter((pkg) => pkg.id.toLowerCase() === needle || pkg.name.toLowerCase().includes(needle));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeItems = items
|
||||||
|
.filter((i) => i.status === "downloading" || i.status === "validating")
|
||||||
|
.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
fileName: i.fileName,
|
||||||
|
status: i.status,
|
||||||
|
fullStatus: i.fullStatus,
|
||||||
|
provider: i.provider,
|
||||||
|
progress: i.progressPercent,
|
||||||
|
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
|
||||||
|
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
|
||||||
|
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
|
||||||
|
retries: i.retries,
|
||||||
|
lastError: i.lastError
|
||||||
|
}));
|
||||||
|
|
||||||
|
const failedItems = items
|
||||||
|
.filter((i) => i.status === "failed")
|
||||||
|
.map((i) => ({
|
||||||
|
fileName: i.fileName,
|
||||||
|
lastError: i.lastError,
|
||||||
|
retries: i.retries,
|
||||||
|
provider: i.provider
|
||||||
|
}));
|
||||||
|
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
count: packages.length,
|
running: snapshot.session.running,
|
||||||
packages: packages.map((pkg) => summarizePackage(snapshot, pkg, includeItems))
|
paused: snapshot.session.paused,
|
||||||
|
speed: snapshot.speedText,
|
||||||
|
eta: snapshot.etaText,
|
||||||
|
itemCounts: byStatus,
|
||||||
|
totalItems: items.length,
|
||||||
|
packages: packages.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
status: p.status,
|
||||||
|
items: p.itemIds.length
|
||||||
|
})),
|
||||||
|
activeItems,
|
||||||
|
failedItems: failedItems.length > 0 ? failedItems : undefined
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -783,7 +175,9 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
items = items.filter((i) => i.status === filter);
|
items = items.filter((i) => i.status === filter);
|
||||||
}
|
}
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const matchedPkg = findPackage(snapshot, pkg);
|
const pkgLower = pkg.toLowerCase();
|
||||||
|
const matchedPkg = Object.values(snapshot.session.packages)
|
||||||
|
.find((p) => p.name.toLowerCase().includes(pkgLower));
|
||||||
if (matchedPkg) {
|
if (matchedPkg) {
|
||||||
const ids = new Set(matchedPkg.itemIds);
|
const ids = new Set(matchedPkg.itemIds);
|
||||||
items = items.filter((i) => ids.has(i.id));
|
items = items.filter((i) => ids.has(i.id));
|
||||||
@ -791,7 +185,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
}
|
}
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
count: items.length,
|
count: items.length,
|
||||||
items: items.map((i) => summarizeItem(i))
|
items: items.map((i) => ({
|
||||||
|
fileName: i.fileName,
|
||||||
|
status: i.status,
|
||||||
|
fullStatus: i.fullStatus,
|
||||||
|
provider: i.provider,
|
||||||
|
progress: i.progressPercent,
|
||||||
|
speedMBs: +(i.speedBps / 1024 / 1024).toFixed(2),
|
||||||
|
downloadedMB: +(i.downloadedBytes / 1024 / 1024).toFixed(1),
|
||||||
|
totalMB: i.totalBytes ? +(i.totalBytes / 1024 / 1024).toFixed(1) : null,
|
||||||
|
retries: i.retries,
|
||||||
|
lastError: i.lastError
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -804,14 +209,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
const snapshot = manager.getSnapshot();
|
const snapshot = manager.getSnapshot();
|
||||||
const pkg = url.searchParams.get("package");
|
const pkg = url.searchParams.get("package");
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const matchedPkg = findPackage(snapshot, pkg);
|
const pkgLower = pkg.toLowerCase();
|
||||||
|
const matchedPkg = Object.values(snapshot.session.packages)
|
||||||
|
.find((p) => p.name.toLowerCase().includes(pkgLower));
|
||||||
if (matchedPkg) {
|
if (matchedPkg) {
|
||||||
const ids = new Set(matchedPkg.itemIds);
|
const ids = new Set(matchedPkg.itemIds);
|
||||||
const pkgItems = Object.values(snapshot.session.items)
|
const pkgItems = Object.values(snapshot.session.items)
|
||||||
.filter((i) => ids.has(i.id));
|
.filter((i) => ids.has(i.id));
|
||||||
jsonResponse(res, 200, {
|
jsonResponse(res, 200, {
|
||||||
package: summarizePackage(snapshot, matchedPkg, false),
|
package: matchedPkg,
|
||||||
items: pkgItems.map((item) => summarizeItem(item))
|
items: pkgItems
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -831,113 +238,31 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/support/bundle") {
|
|
||||||
if (!manager) {
|
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fileName = getSupportBundleDefaultFileName();
|
|
||||||
const body = buildSupportBundle(manager, runtimeBaseDir);
|
|
||||||
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
|
||||||
fileName,
|
|
||||||
sizeBytes: body.length
|
|
||||||
});
|
|
||||||
binaryResponse(res, 200, body, "application/zip", fileName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname === "/diagnostics") {
|
|
||||||
if (!manager) {
|
|
||||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const lineCount = normalizeLinesParam(url.searchParams.get("lines"), 150);
|
|
||||||
const grep = url.searchParams.get("grep") || "";
|
|
||||||
const packageQuery = url.searchParams.get("package") || "";
|
|
||||||
const mainLogPath = getLogFilePath();
|
|
||||||
const sessionLogPath = getSessionLogPath();
|
|
||||||
const selectedPackage = packageQuery ? findPackage(snapshot, packageQuery) : null;
|
|
||||||
const packageLogPath = selectedPackage
|
|
||||||
? manager.getPackageLogPath(selectedPackage.id) || getPersistedPackageLogPath(selectedPackage.id)
|
|
||||||
: null;
|
|
||||||
jsonResponse(res, 200, {
|
|
||||||
meta: {
|
|
||||||
appVersion: APP_VERSION,
|
|
||||||
serverTime: new Date().toISOString(),
|
|
||||||
runtimeBaseDir,
|
|
||||||
debugServer: {
|
|
||||||
host: bindHost,
|
|
||||||
port: bindPort
|
|
||||||
},
|
|
||||||
setup: getDebugSetupCheck(runtimeBaseDir)
|
|
||||||
},
|
|
||||||
status: buildStatusPayload(snapshot),
|
|
||||||
settings: buildRedactedSettingsPayload(readSupportSettings()),
|
|
||||||
stats: buildStatsPayload(snapshot),
|
|
||||||
accounts: buildAccountSummary(readSupportSettings()),
|
|
||||||
history: {
|
|
||||||
total: readSupportHistory().length,
|
|
||||||
recent: readSupportHistory()
|
|
||||||
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((entry) => summarizeHistoryEntry(entry))
|
|
||||||
},
|
|
||||||
host: getWindowsHostDiagnostics(),
|
|
||||||
selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined,
|
|
||||||
logs: {
|
|
||||||
main: {
|
|
||||||
path: mainLogPath,
|
|
||||||
lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep)
|
|
||||||
},
|
|
||||||
audit: {
|
|
||||||
path: getAuditLogPath(),
|
|
||||||
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
|
|
||||||
},
|
|
||||||
rename: {
|
|
||||||
path: getRenameLogPath(),
|
|
||||||
lines: filterLines(readLogTailFromFile(getRenameLogPath(), lineCount), grep)
|
|
||||||
},
|
|
||||||
trace: {
|
|
||||||
path: getTraceLogPath(),
|
|
||||||
config: getTraceConfig(),
|
|
||||||
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
path: sessionLogPath,
|
|
||||||
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
|
||||||
},
|
|
||||||
package: selectedPackage ? {
|
|
||||||
path: packageLogPath,
|
|
||||||
lines: filterLines(readLogTailFromFile(packageLogPath, lineCount), grep)
|
|
||||||
} : undefined
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonResponse(res, 404, {
|
jsonResponse(res, 404, {
|
||||||
error: "Not found",
|
error: "Not found",
|
||||||
endpoints: getEndpointSummaries()
|
endpoints: [
|
||||||
|
"GET /health",
|
||||||
|
"GET /log?lines=100&grep=keyword",
|
||||||
|
"GET /status",
|
||||||
|
"GET /items?status=downloading&package=Bloodline",
|
||||||
|
"GET /session?package=Criminal"
|
||||||
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||||
runtimeBaseDir = baseDir;
|
|
||||||
authToken = loadToken(baseDir);
|
authToken = loadToken(baseDir);
|
||||||
bindPort = getPort(baseDir);
|
|
||||||
bindHost = getHost(baseDir);
|
|
||||||
writeAiManifest(baseDir);
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
manager = mgr;
|
manager = mgr;
|
||||||
|
const port = getPort(baseDir);
|
||||||
|
|
||||||
server = http.createServer(handleRequest);
|
server = http.createServer(handleRequest);
|
||||||
server.listen(bindPort, bindHost, () => {
|
server.listen(port, "127.0.0.1", () => {
|
||||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
logger.info(`Debug-Server gestartet auf Port ${port}`);
|
||||||
});
|
});
|
||||||
server.on("error", (err) => {
|
server.on("error", (err) => {
|
||||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||||
|
|||||||
@ -1,435 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { getSessionLogPath } from "./session-log";
|
|
||||||
import { createStoragePaths, loadSettings } from "./storage";
|
|
||||||
import type {
|
|
||||||
DebugSetupCheckResult,
|
|
||||||
SupportBundleEstimate,
|
|
||||||
SupportDirectorySizeInfo,
|
|
||||||
SupportDiskSpaceInfo,
|
|
||||||
SupportFileSizeInfo,
|
|
||||||
SupportTraceConfig
|
|
||||||
} from "../shared/types";
|
|
||||||
|
|
||||||
const DEFAULT_PORT = 9868;
|
|
||||||
const DEFAULT_HOST = "127.0.0.1";
|
|
||||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
|
||||||
const LOW_FREE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_BYTES || 20 * 1024 * 1024 * 1024);
|
|
||||||
const LOW_FREE_PERCENT_THRESHOLD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT || 5);
|
|
||||||
const LOW_FREE_PERCENT_BYTES_GUARD = Number(process.env.RD_SELF_CHECK_LOW_FREE_PERCENT_BYTES_GUARD || 50 * 1024 * 1024 * 1024);
|
|
||||||
const LARGE_LOG_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_LOG_BYTES || 250 * 1024 * 1024);
|
|
||||||
const LARGE_BUNDLE_BYTES_THRESHOLD = Number(process.env.RD_SELF_CHECK_LARGE_BUNDLE_BYTES || 150 * 1024 * 1024);
|
|
||||||
const BUNDLE_OVERVIEW_SLACK_BYTES = 256 * 1024;
|
|
||||||
|
|
||||||
function formatByteCount(bytes: number): string {
|
|
||||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
||||||
return "0 B";
|
|
||||||
}
|
|
||||||
if (bytes < 1024) {
|
|
||||||
return `${bytes} B`;
|
|
||||||
}
|
|
||||||
if (bytes < 1024 * 1024) {
|
|
||||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
}
|
|
||||||
if (bytes < 1024 * 1024 * 1024) {
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
||||||
}
|
|
||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readToken(baseDir: string): string {
|
|
||||||
try {
|
|
||||||
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPort(baseDir: string): number {
|
|
||||||
try {
|
|
||||||
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
|
|
||||||
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return DEFAULT_PORT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readHost(baseDir: string): string {
|
|
||||||
try {
|
|
||||||
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
|
|
||||||
if (!raw) {
|
|
||||||
return DEFAULT_HOST;
|
|
||||||
}
|
|
||||||
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (/^[a-z0-9.-]+$/i.test(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return DEFAULT_HOST;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readTraceConfig(baseDir: string): SupportTraceConfig {
|
|
||||||
const fallback: SupportTraceConfig = {
|
|
||||||
enabled: false,
|
|
||||||
includeMainLog: true,
|
|
||||||
includeAudit: true,
|
|
||||||
logDebugRequests: true,
|
|
||||||
autoDisableAt: null,
|
|
||||||
updatedAt: new Date(0).toISOString()
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const filePath = path.join(baseDir, "trace_config.json");
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
|
|
||||||
return {
|
|
||||||
enabled: Boolean(parsed.enabled),
|
|
||||||
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
|
|
||||||
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
|
|
||||||
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
|
|
||||||
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
|
|
||||||
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileSizeInfo(filePath: string | null): SupportFileSizeInfo {
|
|
||||||
if (!filePath) {
|
|
||||||
return { path: null, exists: false, bytes: 0 };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
return {
|
|
||||||
path: filePath,
|
|
||||||
exists: true,
|
|
||||||
bytes: stat.size
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
path: filePath,
|
|
||||||
exists: false,
|
|
||||||
bytes: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): SupportDirectorySizeInfo {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
return {
|
|
||||||
path: dirPath,
|
|
||||||
exists: false,
|
|
||||||
fileCount: 0,
|
|
||||||
bytes: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = 0;
|
|
||||||
let fileCount = 0;
|
|
||||||
const queue = [dirPath];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.pop();
|
|
||||||
if (!current) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let entries: fs.Dirent[];
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(current, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
queue.push(fullPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (skipPath && path.resolve(fullPath) === path.resolve(skipPath)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
bytes += fs.statSync(fullPath).size;
|
|
||||||
fileCount += 1;
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: dirPath,
|
|
||||||
exists: true,
|
|
||||||
fileCount,
|
|
||||||
bytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveExistingPath(targetPath: string): string {
|
|
||||||
let current = path.resolve(targetPath);
|
|
||||||
while (!fs.existsSync(current)) {
|
|
||||||
const parent = path.dirname(current);
|
|
||||||
if (parent === current) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
current = parent;
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWindowsDiskSpaceInfo(existingPath: string): SupportDiskSpaceInfo | null {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const root = path.parse(existingPath).root.replace(/[\\/]+$/g, "");
|
|
||||||
const driveName = root.replace(":", "");
|
|
||||||
if (!/^[A-Za-z]$/.test(driveName)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = execFileSync(
|
|
||||||
"powershell",
|
|
||||||
[
|
|
||||||
"-NoProfile",
|
|
||||||
"-Command",
|
|
||||||
`$drive = Get-PSDrive -Name '${driveName}'; if ($drive) { [pscustomobject]@{ FreeSpace = [int64]$drive.Free; Size = [int64]($drive.Used + $drive.Free) } | ConvertTo-Json -Compress }`
|
|
||||||
],
|
|
||||||
{
|
|
||||||
encoding: "utf8",
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["ignore", "pipe", "ignore"],
|
|
||||||
timeout: 3000
|
|
||||||
}
|
|
||||||
).trim();
|
|
||||||
if (!raw) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as { FreeSpace?: number | string; Size?: number | string };
|
|
||||||
const totalBytes = Number(parsed.Size);
|
|
||||||
const freeBytes = Number(parsed.FreeSpace);
|
|
||||||
const freePercent = Number.isFinite(totalBytes) && totalBytes > 0
|
|
||||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
path: existingPath,
|
|
||||||
totalBytes: Number.isFinite(totalBytes) ? totalBytes : null,
|
|
||||||
freeBytes: Number.isFinite(freeBytes) ? freeBytes : null,
|
|
||||||
freePercent
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDiskSpaceInfo(targetPath: string): SupportDiskSpaceInfo {
|
|
||||||
const existingPath = resolveExistingPath(targetPath);
|
|
||||||
try {
|
|
||||||
const stat = fs.statfsSync(existingPath);
|
|
||||||
const totalBytes = Number(stat.blocks) * Number(stat.bsize);
|
|
||||||
const freeBytes = Number(stat.bavail) * Number(stat.bsize);
|
|
||||||
const freePercent = totalBytes > 0
|
|
||||||
? Math.round((freeBytes / totalBytes) * 1000) / 10
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
path: existingPath,
|
|
||||||
totalBytes,
|
|
||||||
freeBytes,
|
|
||||||
freePercent
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
const windowsFallback = getWindowsDiskSpaceInfo(existingPath);
|
|
||||||
if (windowsFallback) {
|
|
||||||
return windowsFallback;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
path: existingPath,
|
|
||||||
totalBytes: null,
|
|
||||||
freeBytes: null,
|
|
||||||
freePercent: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSupportBundleEstimate(
|
|
||||||
baseDir: string,
|
|
||||||
logSummary: DebugSetupCheckResult["logSummary"]
|
|
||||||
): SupportBundleEstimate {
|
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
|
||||||
const staticFiles = [
|
|
||||||
path.join(baseDir, AI_MANIFEST_FILE),
|
|
||||||
path.join(baseDir, "debug_host.txt"),
|
|
||||||
path.join(baseDir, "debug_port.txt"),
|
|
||||||
storagePaths.configFile,
|
|
||||||
storagePaths.sessionFile,
|
|
||||||
storagePaths.historyFile,
|
|
||||||
path.join(baseDir, "trace_config.json")
|
|
||||||
].map((filePath) => getFileSizeInfo(filePath));
|
|
||||||
|
|
||||||
const staticBytes = staticFiles.reduce((sum, entry) => sum + entry.bytes, 0);
|
|
||||||
const duplicatedLiveLogBytes = logSummary.session.bytes + logSummary.packageLogs.bytes + logSummary.itemLogs.bytes;
|
|
||||||
const estimatedEntries = 10
|
|
||||||
+ staticFiles.filter((entry) => entry.exists).length
|
|
||||||
+ Number(logSummary.main.exists)
|
|
||||||
+ Number(logSummary.mainBackup.exists)
|
|
||||||
+ Number(logSummary.audit.exists)
|
|
||||||
+ Number(logSummary.auditBackup.exists)
|
|
||||||
+ Number(logSummary.rename.exists)
|
|
||||||
+ Number(logSummary.renameBackup.exists)
|
|
||||||
+ Number(logSummary.session.exists)
|
|
||||||
+ Number(logSummary.trace.exists)
|
|
||||||
+ Number(logSummary.traceBackup.exists)
|
|
||||||
+ logSummary.sessionLogs.fileCount
|
|
||||||
+ logSummary.packageLogs.fileCount
|
|
||||||
+ logSummary.itemLogs.fileCount
|
|
||||||
+ logSummary.packageLogs.fileCount
|
|
||||||
+ logSummary.itemLogs.fileCount;
|
|
||||||
|
|
||||||
return {
|
|
||||||
estimatedBytes: staticBytes + logSummary.totalBytes + duplicatedLiveLogBytes + BUNDLE_OVERVIEW_SLACK_BYTES,
|
|
||||||
estimatedEntries,
|
|
||||||
duplicatedLiveLogBytes,
|
|
||||||
note: "Schätzwert vor ZIP-Komprimierung; aktueller Session-Log sowie Live-Paket-/Item-Logs werden im Bundle zusätzlich gespiegelt."
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
|
||||||
const host = readHost(baseDir);
|
|
||||||
const port = readPort(baseDir);
|
|
||||||
const token = readToken(baseDir);
|
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
|
||||||
const settings = loadSettings(storagePaths);
|
|
||||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
|
||||||
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
|
|
||||||
const traceConfigPath = path.join(baseDir, "trace_config.json");
|
|
||||||
const traceLogPath = path.join(baseDir, "trace.log");
|
|
||||||
const traceConfig = readTraceConfig(baseDir);
|
|
||||||
const sessionLogPath = getSessionLogPath();
|
|
||||||
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const notes: string[] = [];
|
|
||||||
|
|
||||||
const logSummary: DebugSetupCheckResult["logSummary"] = {
|
|
||||||
main: getFileSizeInfo(path.join(baseDir, "rd_downloader.log")),
|
|
||||||
mainBackup: getFileSizeInfo(path.join(baseDir, "rd_downloader.log.old")),
|
|
||||||
audit: getFileSizeInfo(path.join(baseDir, "audit.log")),
|
|
||||||
auditBackup: getFileSizeInfo(path.join(baseDir, "audit.log.old")),
|
|
||||||
rename: getFileSizeInfo(path.join(baseDir, "rename.log")),
|
|
||||||
renameBackup: getFileSizeInfo(path.join(baseDir, "rename.log.old")),
|
|
||||||
session: getFileSizeInfo(sessionLogPath),
|
|
||||||
trace: getFileSizeInfo(traceLogPath),
|
|
||||||
traceBackup: getFileSizeInfo(path.join(baseDir, "trace.log.old")),
|
|
||||||
sessionLogs: getDirectorySizeInfo(path.join(baseDir, "session-logs"), sessionLogPath),
|
|
||||||
packageLogs: getDirectorySizeInfo(path.join(baseDir, "package-logs")),
|
|
||||||
itemLogs: getDirectorySizeInfo(path.join(baseDir, "item-logs")),
|
|
||||||
totalBytes: 0
|
|
||||||
};
|
|
||||||
logSummary.totalBytes = [
|
|
||||||
logSummary.main.bytes,
|
|
||||||
logSummary.mainBackup.bytes,
|
|
||||||
logSummary.audit.bytes,
|
|
||||||
logSummary.auditBackup.bytes,
|
|
||||||
logSummary.rename.bytes,
|
|
||||||
logSummary.renameBackup.bytes,
|
|
||||||
logSummary.session.bytes,
|
|
||||||
logSummary.trace.bytes,
|
|
||||||
logSummary.traceBackup.bytes,
|
|
||||||
logSummary.sessionLogs.bytes,
|
|
||||||
logSummary.packageLogs.bytes,
|
|
||||||
logSummary.itemLogs.bytes
|
|
||||||
].reduce((sum, value) => sum + value, 0);
|
|
||||||
|
|
||||||
const diskSpace: DebugSetupCheckResult["diskSpace"] = {
|
|
||||||
runtime: getDiskSpaceInfo(baseDir),
|
|
||||||
output: getDiskSpaceInfo(settings.outputDir),
|
|
||||||
extract: getDiskSpaceInfo(settings.extractDir)
|
|
||||||
};
|
|
||||||
const supportBundle = getSupportBundleEstimate(baseDir, logSummary);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
|
||||||
}
|
|
||||||
if (localOnly) {
|
|
||||||
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
|
|
||||||
} else {
|
|
||||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(aiManifestPath)) {
|
|
||||||
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
|
|
||||||
}
|
|
||||||
if (!fs.existsSync(traceConfigPath)) {
|
|
||||||
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
|
|
||||||
}
|
|
||||||
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
|
|
||||||
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
|
|
||||||
}
|
|
||||||
if (traceConfig.enabled && traceConfig.autoDisableAt) {
|
|
||||||
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of [
|
|
||||||
{ label: "Runtime", info: diskSpace.runtime },
|
|
||||||
{ label: "Download-Ziel", info: diskSpace.output },
|
|
||||||
{ label: "Entpack-Ziel", info: diskSpace.extract }
|
|
||||||
]) {
|
|
||||||
if (entry.info.freeBytes === null || entry.info.totalBytes === null) {
|
|
||||||
warnings.push(`${entry.label}: Freier Speicherplatz konnte nicht gelesen werden (${entry.info.path}).`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const lowByAbsolute = entry.info.freeBytes < LOW_FREE_BYTES_THRESHOLD;
|
|
||||||
const lowByPercent = entry.info.freePercent !== null
|
|
||||||
&& entry.info.freePercent < LOW_FREE_PERCENT_THRESHOLD
|
|
||||||
&& entry.info.freeBytes < LOW_FREE_PERCENT_BYTES_GUARD;
|
|
||||||
if (lowByAbsolute || lowByPercent) {
|
|
||||||
warnings.push(`${entry.label}: wenig freier Speicherplatz (${formatByteCount(entry.info.freeBytes)} frei auf ${entry.info.path}).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (logSummary.totalBytes >= LARGE_LOG_BYTES_THRESHOLD) {
|
|
||||||
warnings.push(`Support-Logs sind bereits recht groß (${formatByteCount(logSummary.totalBytes)}). Rotation greift, aber ein Bundle wird entsprechend umfangreicher.`);
|
|
||||||
} else {
|
|
||||||
notes.push(`Aktuelle Support-Logmenge: ${formatByteCount(logSummary.totalBytes)}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportBundle.estimatedBytes >= LARGE_BUNDLE_BYTES_THRESHOLD) {
|
|
||||||
warnings.push(`Support-Bundle wird voraussichtlich groß (${formatByteCount(supportBundle.estimatedBytes)} vor ZIP-Komprimierung).`);
|
|
||||||
} else {
|
|
||||||
notes.push(`Support-Bundle-Schätzung: etwa ${formatByteCount(supportBundle.estimatedBytes)}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: warnings.length > 0 ? "warn" : "ok",
|
|
||||||
enabled: Boolean(token),
|
|
||||||
runtimeBaseDir: baseDir,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
localOnly,
|
|
||||||
tokenConfigured: Boolean(token),
|
|
||||||
tokenPath,
|
|
||||||
aiManifestPath,
|
|
||||||
aiManifestPresent: fs.existsSync(aiManifestPath),
|
|
||||||
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
|
|
||||||
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
|
|
||||||
traceEnabled: traceConfig.enabled,
|
|
||||||
traceAutoDisableAt: traceConfig.autoDisableAt,
|
|
||||||
diskSpace,
|
|
||||||
logSummary,
|
|
||||||
supportBundle,
|
|
||||||
warnings,
|
|
||||||
notes,
|
|
||||||
localUrls: {
|
|
||||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
|
||||||
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
|
||||||
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
|
||||||
},
|
|
||||||
remoteUrlTemplates: {
|
|
||||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
|
||||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
|
||||||
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,252 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
|
|
||||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
const FOLDER_NAME = "Downloader-Log";
|
|
||||||
|
|
||||||
let logDir: string | null = null;
|
|
||||||
let logFilePath: string | null = null;
|
|
||||||
let sessionHeader = "";
|
|
||||||
|
|
||||||
function fileTimestamp(date: Date = new Date()): string {
|
|
||||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
|
||||||
+ `${pad(date.getHours())}-${pad(date.getMinutes())}-${pad(date.getSeconds())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureWritable(): boolean {
|
|
||||||
if (!logDir || !logFilePath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
|
||||||
if (!fs.existsSync(logFilePath)) {
|
|
||||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
|
||||||
try {
|
|
||||||
const base = String(desktopDir || "").trim();
|
|
||||||
if (!base) {
|
|
||||||
logDir = null;
|
|
||||||
logFilePath = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logDir = path.join(base, FOLDER_NAME);
|
|
||||||
logFilePath = path.join(logDir, `rename-session_${fileTimestamp()}.txt`);
|
|
||||||
sessionHeader = `=== Rename-Session gestartet: ${logTimestamp()} ===\n`
|
|
||||||
+ "Diese Datei protokolliert JEDEN Umbenenn-/Verschiebevorgang dieser Programm-Sitzung\n"
|
|
||||||
+ "und verifiziert nach jedem Vorgang, ob die Datei wirklich unter dem Zielnamen auf der\n"
|
|
||||||
+ "Platte liegt (und die Quelle verschwunden ist). [INFO]=ok, [ERROR]=Verifikation gescheitert.\n\n";
|
|
||||||
fs.mkdirSync(logDir, { recursive: true });
|
|
||||||
fs.writeFileSync(logFilePath, sessionHeader, "utf8");
|
|
||||||
} catch {
|
|
||||||
logDir = null;
|
|
||||||
logFilePath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
|
||||||
if (!ensureWritable() || !logFilePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDesktopRenameLogPath(): string | null {
|
|
||||||
if (!logFilePath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return fs.existsSync(logFilePath) ? logFilePath : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownDesktopRenameLog(): void {
|
|
||||||
if (ensureWritable() && logFilePath) {
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logDir = null;
|
|
||||||
logFilePath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenameVerification {
|
|
||||||
ok: boolean;
|
|
||||||
level: "INFO" | "WARN" | "ERROR";
|
|
||||||
targetExists: boolean;
|
|
||||||
onDiskName: string | null;
|
|
||||||
nameMatches: boolean;
|
|
||||||
sourceGone: boolean;
|
|
||||||
targetSize: number | null;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLongPath(filePath: string): string {
|
|
||||||
const absolute = path.resolve(String(filePath || ""));
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return absolute;
|
|
||||||
}
|
|
||||||
if (!absolute || absolute.startsWith("\\\\?\\")) {
|
|
||||||
return absolute;
|
|
||||||
}
|
|
||||||
if (absolute.length < 248) {
|
|
||||||
return absolute;
|
|
||||||
}
|
|
||||||
if (absolute.startsWith("\\\\")) {
|
|
||||||
return `\\\\?\\UNC\\${absolute.slice(2)}`;
|
|
||||||
}
|
|
||||||
return `\\\\?\\${absolute}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
|
||||||
if (entries === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const requestedLower = requested.toLowerCase();
|
|
||||||
return entries.find((entry) => entry === requested)
|
|
||||||
|| entries.find((entry) => entry.toLowerCase() === requestedLower)
|
|
||||||
|| requested;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildVerification(
|
|
||||||
sourcePath: string,
|
|
||||||
targetPath: string,
|
|
||||||
facts: { targetExists: boolean; targetSize: number | null; dirEntries: string[] | null; sourceExists: boolean }
|
|
||||||
): RenameVerification {
|
|
||||||
const requested = path.basename(targetPath);
|
|
||||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
|
||||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
|
||||||
|
|
||||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
|
||||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
|
||||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
|
||||||
|
|
||||||
const problems: string[] = [];
|
|
||||||
let level: "INFO" | "WARN" | "ERROR" = "INFO";
|
|
||||||
if (!facts.targetExists) {
|
|
||||||
problems.push("Zieldatei nach Rename NICHT gefunden");
|
|
||||||
level = "ERROR";
|
|
||||||
} else if (!dirReadFailed && !nameMatches) {
|
|
||||||
problems.push(`On-Disk-Name weicht ab (ist "${onDiskName}", erwartet "${requested}")`);
|
|
||||||
level = "ERROR";
|
|
||||||
}
|
|
||||||
if (!samePath && facts.targetExists && !sourceGone) {
|
|
||||||
problems.push("Quelldatei existiert noch (moeglicher halb-fertiger Verschiebevorgang)");
|
|
||||||
level = "ERROR";
|
|
||||||
}
|
|
||||||
if (level === "INFO" && dirReadFailed) {
|
|
||||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
|
||||||
level = "WARN";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: level === "INFO",
|
|
||||||
level,
|
|
||||||
targetExists: facts.targetExists,
|
|
||||||
onDiskName,
|
|
||||||
nameMatches,
|
|
||||||
sourceGone,
|
|
||||||
targetSize: facts.targetSize,
|
|
||||||
reason: problems.join("; ")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
|
||||||
const longTarget = toLongPath(targetPath);
|
|
||||||
let targetExists = false;
|
|
||||||
let targetSize: number | null = null;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(longTarget);
|
|
||||||
targetExists = true;
|
|
||||||
targetSize = stat.size;
|
|
||||||
} catch {
|
|
||||||
targetExists = false;
|
|
||||||
}
|
|
||||||
let dirEntries: string[] | null = null;
|
|
||||||
if (targetExists) {
|
|
||||||
try {
|
|
||||||
dirEntries = fs.readdirSync(path.dirname(longTarget));
|
|
||||||
} catch {
|
|
||||||
dirEntries = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let sourceExists = false;
|
|
||||||
try {
|
|
||||||
fs.statSync(toLongPath(sourcePath));
|
|
||||||
sourceExists = true;
|
|
||||||
} catch {
|
|
||||||
sourceExists = false;
|
|
||||||
}
|
|
||||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
|
||||||
const longTarget = toLongPath(targetPath);
|
|
||||||
let targetExists = false;
|
|
||||||
let targetSize: number | null = null;
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.stat(longTarget);
|
|
||||||
targetExists = true;
|
|
||||||
targetSize = stat.size;
|
|
||||||
} catch {
|
|
||||||
targetExists = false;
|
|
||||||
}
|
|
||||||
let dirEntries: string[] | null = null;
|
|
||||||
if (targetExists) {
|
|
||||||
try {
|
|
||||||
dirEntries = await fs.promises.readdir(path.dirname(longTarget));
|
|
||||||
} catch {
|
|
||||||
dirEntries = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let sourceExists = false;
|
|
||||||
try {
|
|
||||||
await fs.promises.stat(toLongPath(sourcePath));
|
|
||||||
sourceExists = true;
|
|
||||||
} catch {
|
|
||||||
sourceExists = false;
|
|
||||||
}
|
|
||||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
|
||||||
}
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
|
||||||
|
|
||||||
export type DownloadCompletionSource =
|
|
||||||
| "content-range"
|
|
||||||
| "content-length"
|
|
||||||
| "provider-metadata"
|
|
||||||
| "stream-end";
|
|
||||||
|
|
||||||
export type DownloadCompletionPlan = {
|
|
||||||
expectedTotal: number | null;
|
|
||||||
source: DownloadCompletionSource;
|
|
||||||
canFinishEarly: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function planDownloadCompletion(args: {
|
|
||||||
existingBytes: number;
|
|
||||||
responseStatus: number;
|
|
||||||
contentLength: number;
|
|
||||||
totalFromRange: number | null;
|
|
||||||
knownTotal: number | null;
|
|
||||||
correctedTotal: number | null;
|
|
||||||
}): DownloadCompletionPlan {
|
|
||||||
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
|
||||||
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
|
||||||
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
|
||||||
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
|
||||||
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
|
||||||
: 0;
|
|
||||||
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
|
||||||
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
|
||||||
: 0;
|
|
||||||
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
|
||||||
? Math.max(0, Math.floor(args.knownTotal || 0))
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (correctedTotal > 0) {
|
|
||||||
return {
|
|
||||||
expectedTotal: correctedTotal,
|
|
||||||
source: totalFromRange > 0 ? "content-range" : "content-length",
|
|
||||||
canFinishEarly: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalFromRange > 0) {
|
|
||||||
return {
|
|
||||||
expectedTotal: totalFromRange,
|
|
||||||
source: "content-range",
|
|
||||||
canFinishEarly: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contentLength > 0) {
|
|
||||||
return {
|
|
||||||
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
|
||||||
source: "content-length",
|
|
||||||
canFinishEarly: true
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (knownTotal > 0) {
|
|
||||||
return {
|
|
||||||
expectedTotal: knownTotal,
|
|
||||||
source: "provider-metadata",
|
|
||||||
canFinishEarly: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
expectedTotal: null,
|
|
||||||
source: "stream-end",
|
|
||||||
canFinishEarly: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateDownloadedFileCompletion(args: {
|
|
||||||
actualBytes: number;
|
|
||||||
plan: DownloadCompletionPlan;
|
|
||||||
toleranceBytes?: number;
|
|
||||||
}): {
|
|
||||||
ok: boolean;
|
|
||||||
totalBytes: number;
|
|
||||||
acceptedMetadataMismatch: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
|
||||||
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
|
||||||
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
|
||||||
: 0;
|
|
||||||
const toleranceBytes = Math.max(0, Math.floor(Number(args.toleranceBytes ?? ALLOCATION_UNIT_SIZE) || 0));
|
|
||||||
|
|
||||||
if (
|
|
||||||
expectedTotal > 0 &&
|
|
||||||
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
|
||||||
actualBytes + toleranceBytes < expectedTotal
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
totalBytes: expectedTotal,
|
|
||||||
acceptedMetadataMismatch: false,
|
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actualBytes <= 0 && expectedTotal > 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
totalBytes: expectedTotal,
|
|
||||||
acceptedMetadataMismatch: false,
|
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.plan.source === "provider-metadata") {
|
|
||||||
if (expectedTotal > 0 && actualBytes + toleranceBytes < expectedTotal) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
totalBytes: expectedTotal,
|
|
||||||
acceptedMetadataMismatch: false,
|
|
||||||
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
totalBytes: actualBytes,
|
|
||||||
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > toleranceBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.plan.source === "stream-end") {
|
|
||||||
if (actualBytes <= 0) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
totalBytes: 0,
|
|
||||||
acceptedMetadataMismatch: false,
|
|
||||||
error: "download_underflow:0/0"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
totalBytes: actualBytes,
|
|
||||||
acceptedMetadataMismatch: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
totalBytes: Math.max(actualBytes, expectedTotal),
|
|
||||||
acceptedMetadataMismatch: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,45 +0,0 @@
|
|||||||
export interface ErrorRingEntry {
|
|
||||||
ts: string;
|
|
||||||
level: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorRing {
|
|
||||||
push: (entry: ErrorRingEntry) => void;
|
|
||||||
snapshot: () => ErrorRingEntry[];
|
|
||||||
clear: () => void;
|
|
||||||
size: () => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createErrorRing(capacity: number): ErrorRing {
|
|
||||||
const limit = Math.max(1, Math.floor(capacity));
|
|
||||||
const buffer: ErrorRingEntry[] = [];
|
|
||||||
return {
|
|
||||||
push(entry: ErrorRingEntry): void {
|
|
||||||
buffer.push(entry);
|
|
||||||
while (buffer.length > limit) {
|
|
||||||
buffer.shift();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
snapshot(): ErrorRingEntry[] {
|
|
||||||
return buffer.slice();
|
|
||||||
},
|
|
||||||
clear(): void {
|
|
||||||
buffer.length = 0;
|
|
||||||
},
|
|
||||||
size(): number {
|
|
||||||
return buffer.length;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const RECENT_ERROR_CAPACITY = 200;
|
|
||||||
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
|
|
||||||
|
|
||||||
export function recordRecentError(level: string, message: string, ts: string): void {
|
|
||||||
recentErrors.push({ level, message, ts });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRecentErrors(): ErrorRingEntry[] {
|
|
||||||
return recentErrors.snapshot();
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,56 +0,0 @@
|
|||||||
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
|
|
||||||
// generic "write failed" or "timeout" can be reported as the specific root cause
|
|
||||||
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
|
|
||||||
|
|
||||||
const DISK_ERROR_REASONS: Record<string, string> = {
|
|
||||||
ENOSPC: "Festplatte voll (ENOSPC)",
|
|
||||||
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
|
|
||||||
EROFS: "Laufwerk schreibgeschützt (EROFS)",
|
|
||||||
EACCES: "Zugriff verweigert (EACCES)",
|
|
||||||
EPERM: "Operation nicht erlaubt (EPERM)",
|
|
||||||
EMFILE: "Zu viele offene Dateien (EMFILE)",
|
|
||||||
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
|
|
||||||
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
|
|
||||||
ENODEV: "Gerät nicht vorhanden (ENODEV)",
|
|
||||||
ENXIO: "Gerät getrennt (ENXIO)",
|
|
||||||
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
|
|
||||||
};
|
|
||||||
|
|
||||||
export function classifyDiskError(err: unknown): string | null {
|
|
||||||
const code = extractErrorCode(err);
|
|
||||||
if (code && DISK_ERROR_REASONS[code]) {
|
|
||||||
return DISK_ERROR_REASONS[code];
|
|
||||||
}
|
|
||||||
// Some errors arrive as plain strings/messages without a `.code`; fall back to
|
|
||||||
// scanning the text for a known code token.
|
|
||||||
const text = errorText(err);
|
|
||||||
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
|
|
||||||
if (text.includes(knownCode)) {
|
|
||||||
return DISK_ERROR_REASONS[knownCode];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractErrorCode(err: unknown): string {
|
|
||||||
if (err && typeof err === "object") {
|
|
||||||
const code = (err as { code?: unknown }).code;
|
|
||||||
if (typeof code === "string") {
|
|
||||||
return code.toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorText(err: unknown): string {
|
|
||||||
if (typeof err === "string") {
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
if (err && typeof err === "object") {
|
|
||||||
const message = (err as { message?: unknown }).message;
|
|
||||||
if (typeof message === "string") {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return String(err ?? "");
|
|
||||||
}
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
const ITEM_LOG_FLUSH_INTERVAL_MS = 200;
|
|
||||||
const ITEM_LOG_RETENTION_DAYS = 30;
|
|
||||||
|
|
||||||
type ItemLogLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
export interface ItemLogMeta {
|
|
||||||
itemId: string;
|
|
||||||
packageId: string;
|
|
||||||
packageName: string;
|
|
||||||
fileName: string;
|
|
||||||
targetPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let itemLogsDir: string | null = null;
|
|
||||||
const knownLogPaths = new Map<string, string>();
|
|
||||||
const pendingLinesByItem = new Map<string, string[]>();
|
|
||||||
const initializedThisProcess = new Set<string>();
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
function normalizeItemId(itemId: string): string {
|
|
||||||
const trimmed = String(itemId || "").trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const safePrefix = trimmed
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
||||||
.replace(/_+/g, "_")
|
|
||||||
.slice(0, 64)
|
|
||||||
.replace(/^_+|_+$/g, "");
|
|
||||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
|
||||||
return `${safePrefix || "item"}_${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemLogFilePathFromNormalized(normalized: string): string | null {
|
|
||||||
if (!normalized || !itemLogsDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const existing = knownLogPaths.get(normalized);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const logPath = path.join(itemLogsDir, `item_${normalized}.txt`);
|
|
||||||
knownLogPaths.set(normalized, logPath);
|
|
||||||
return logPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getItemLogFilePath(itemId: string): string | null {
|
|
||||||
return getItemLogFilePathFromNormalized(normalizeItemId(itemId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPending(): void {
|
|
||||||
for (const [itemId, lines] of pendingLinesByItem.entries()) {
|
|
||||||
if (lines.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
|
||||||
if (!logPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const chunk = lines.join("");
|
|
||||||
pendingLinesByItem.set(itemId, []);
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flushTimer = setTimeout(() => {
|
|
||||||
flushTimer = null;
|
|
||||||
flushPending();
|
|
||||||
}, ITEM_LOG_FLUSH_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupOldItemLogs(dir: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const files = await fs.promises.readdir(dir);
|
|
||||||
const cutoff = Date.now() - ITEM_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.startsWith("item_") || !file.endsWith(".txt")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const filePath = path.join(dir, file);
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.stat(filePath);
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
await fs.promises.unlink(filePath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLine(itemId: string, line: string): void {
|
|
||||||
const normalized = normalizeItemId(itemId);
|
|
||||||
if (!normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = pendingLinesByItem.get(normalized) || [];
|
|
||||||
lines.push(line);
|
|
||||||
pendingLinesByItem.set(normalized, lines);
|
|
||||||
scheduleFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initItemLogs(baseDir: string): void {
|
|
||||||
itemLogsDir = path.join(baseDir, "item-logs");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(itemLogsDir, { recursive: true });
|
|
||||||
} catch {
|
|
||||||
itemLogsDir = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void cleanupOldItemLogs(itemLogsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureItemLog(meta: ItemLogMeta): string | null {
|
|
||||||
const normalizedItemId = normalizeItemId(meta.itemId);
|
|
||||||
const logPath = getItemLogFilePath(meta.itemId);
|
|
||||||
if (!logPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
||||||
if (!fs.existsSync(logPath)) {
|
|
||||||
fs.writeFileSync(logPath, "", "utf8");
|
|
||||||
}
|
|
||||||
if (!initializedThisProcess.has(normalizedItemId)) {
|
|
||||||
initializedThisProcess.add(normalizedItemId);
|
|
||||||
const startedAt = logTimestamp();
|
|
||||||
fs.appendFileSync(
|
|
||||||
logPath,
|
|
||||||
`=== Item-Log Start: ${startedAt} | itemId=${sanitizeFieldValue(String(meta.itemId || ""))} | logKey=${normalizedItemId} | fileName=${sanitizeFieldValue(meta.fileName)} ===\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
fs.appendFileSync(
|
|
||||||
logPath,
|
|
||||||
`${logTimestamp()} [INFO] Item-Kontext initialisiert${formatFields({
|
|
||||||
packageId: meta.packageId,
|
|
||||||
packageName: meta.packageName,
|
|
||||||
fileName: meta.fileName,
|
|
||||||
targetPath: meta.targetPath
|
|
||||||
})}\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return logPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logItemEvent(
|
|
||||||
itemId: string,
|
|
||||||
level: ItemLogLevel,
|
|
||||||
message: string,
|
|
||||||
fields?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
const logPath = getItemLogFilePath(itemId);
|
|
||||||
if (!logPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
|
||||||
appendLine(itemId, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getItemLogPath(itemId: string): string | null {
|
|
||||||
const logPath = getItemLogFilePath(itemId);
|
|
||||||
if (!logPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(logPath) ? logPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownItemLogs(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
flushPending();
|
|
||||||
for (const itemId of knownLogPaths.keys()) {
|
|
||||||
const logPath = getItemLogFilePathFromNormalized(itemId);
|
|
||||||
if (!logPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingLinesByItem.clear();
|
|
||||||
knownLogPaths.clear();
|
|
||||||
initializedThisProcess.clear();
|
|
||||||
itemLogsDir = null;
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import type { ParsedPackageInput, UiSnapshot } from "../shared/types";
|
|
||||||
import { sanitizeFilename } from "./utils";
|
|
||||||
|
|
||||||
export type LinkExportSelection = {
|
|
||||||
packages: ParsedPackageInput[];
|
|
||||||
packageCount: number;
|
|
||||||
linkCount: number;
|
|
||||||
defaultFileName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatTimestampForFileName(date: Date): string {
|
|
||||||
const y = date.getFullYear();
|
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
const h = String(date.getHours()).padStart(2, "0");
|
|
||||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
const s = String(date.getSeconds()).padStart(2, "0");
|
|
||||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDefaultFileName(packages: ParsedPackageInput[]): string {
|
|
||||||
if (packages.length === 1) {
|
|
||||||
const only = packages[0];
|
|
||||||
if (only.links.length === 1) {
|
|
||||||
const itemName = sanitizeFilename(only.fileNames?.[0] || only.name || "link-export");
|
|
||||||
return `${itemName}.txt`;
|
|
||||||
}
|
|
||||||
return `${sanitizeFilename(only.name || "paket-export")}.txt`;
|
|
||||||
}
|
|
||||||
return `rd-link-export-${formatTimestampForFileName(new Date())}.txt`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildLinkExportSelection(snapshot: UiSnapshot, packageIds: string[], itemIds: string[]): LinkExportSelection {
|
|
||||||
const selectedPackageIds = new Set(packageIds);
|
|
||||||
const selectedItemIds = new Set(itemIds);
|
|
||||||
const packages: ParsedPackageInput[] = [];
|
|
||||||
|
|
||||||
for (const packageId of snapshot.session.packageOrder) {
|
|
||||||
const pkg = snapshot.session.packages[packageId];
|
|
||||||
if (!pkg) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useWholePackage = selectedPackageIds.has(packageId);
|
|
||||||
const relevantItemIds = useWholePackage
|
|
||||||
? pkg.itemIds
|
|
||||||
: pkg.itemIds.filter((itemId) => selectedItemIds.has(itemId));
|
|
||||||
|
|
||||||
if (relevantItemIds.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const links: string[] = [];
|
|
||||||
const fileNames: string[] = [];
|
|
||||||
for (const itemId of relevantItemIds) {
|
|
||||||
const item = snapshot.session.items[itemId];
|
|
||||||
if (!item || !String(item.url || "").trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
links.push(String(item.url).trim());
|
|
||||||
const rawFileName = String(item.fileName || "").trim();
|
|
||||||
fileNames.push(rawFileName ? sanitizeFilename(rawFileName) : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (links.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportEntry: ParsedPackageInput = {
|
|
||||||
name: sanitizeFilename(pkg.name || "Paket"),
|
|
||||||
links
|
|
||||||
};
|
|
||||||
if (fileNames.some((fileName) => fileName.length > 0)) {
|
|
||||||
exportEntry.fileNames = fileNames;
|
|
||||||
}
|
|
||||||
packages.push(exportEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkCount = packages.reduce((sum, pkg) => sum + pkg.links.length, 0);
|
|
||||||
return {
|
|
||||||
packages,
|
|
||||||
packageCount: packages.length,
|
|
||||||
linkCount,
|
|
||||||
defaultFileName: buildDefaultFileName(packages)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeLinkExportText(packages: ParsedPackageInput[]): string {
|
|
||||||
const lines: string[] = [
|
|
||||||
"# rd-link-export: 1",
|
|
||||||
"# Re-import in Real-Debrid-Downloader keeps package names and optional file names.",
|
|
||||||
""
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pkg of packages) {
|
|
||||||
if (!pkg || !pkg.name || !Array.isArray(pkg.links) || pkg.links.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push(`# package: ${sanitizeFilename(pkg.name)}`);
|
|
||||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
|
||||||
const link = String(pkg.links[index] || "").trim();
|
|
||||||
if (!link) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
|
||||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
|
||||||
if (fileName) {
|
|
||||||
lines.push(`# file: ${fileName}`);
|
|
||||||
}
|
|
||||||
lines.push(link);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${lines.join("\n").trim()}\n`;
|
|
||||||
}
|
|
||||||
@ -2,35 +2,19 @@ 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, 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 list = grouped.get(name) ?? [];
|
||||||
for (let index = 0; index < pkg.links.length; index += 1) {
|
for (const link of pkg.links) {
|
||||||
const link = String(pkg.links[index] || "").trim();
|
list.push(link);
|
||||||
if (!link) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!current.links.includes(link)) {
|
|
||||||
current.links.push(link);
|
|
||||||
}
|
|
||||||
const rawFileName = String(pkg.fileNames?.[index] || "").trim();
|
|
||||||
const fileName = rawFileName ? sanitizeFilename(rawFileName) : "";
|
|
||||||
if (fileName && !current.fileNameByLink.has(link)) {
|
|
||||||
current.fileNameByLink.set(link, fileName);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
grouped.set(name, current);
|
grouped.set(name, list);
|
||||||
}
|
}
|
||||||
return Array.from(grouped.entries()).map(([name, entry]) => {
|
return Array.from(grouped.entries()).map(([name, links]) => ({
|
||||||
const links = uniquePreserveOrder(entry.links);
|
name,
|
||||||
const fileNames = links.map((link) => entry.fileNameByLink.get(link) || "");
|
links: uniquePreserveOrder(links)
|
||||||
return {
|
}));
|
||||||
name,
|
|
||||||
links,
|
|
||||||
...(fileNames.some((fileName) => fileName.length > 0) ? { fileNames } : {})
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
export function parseCollectorInput(rawText: string, packageName = ""): ParsedPackageInput[] {
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
export function logTimestamp(date: Date = new Date()): string {
|
|
||||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
|
||||||
const offsetMinutes = -date.getTimezoneOffset();
|
|
||||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
||||||
const absOffset = Math.abs(offsetMinutes);
|
|
||||||
const offset = `${sign}${pad(Math.floor(absOffset / 60))}:${pad(absOffset % 60)}`;
|
|
||||||
return (
|
|
||||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
||||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offset}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,24 +1,6 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import { recordRecentError } from "./error-ring";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
export function isDebugFlagEnabled(value: string | undefined): boolean {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return /^(1|true|yes|on)$/i.test(value.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
|
|
||||||
// is a deliberate support action that requires a restart — the runtime-toggleable
|
|
||||||
// channel is the trace log, not this.
|
|
||||||
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
|
|
||||||
|
|
||||||
export function isDebugLoggingEnabled(): boolean {
|
|
||||||
return DEBUG_ENABLED;
|
|
||||||
}
|
|
||||||
|
|
||||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
let 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;
|
||||||
@ -27,8 +9,7 @@ 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>();
|
let logListener: LogListener | null = null;
|
||||||
let legacyLogListener: LogListener | null = null;
|
|
||||||
|
|
||||||
let pendingLines: string[] = [];
|
let pendingLines: string[] = [];
|
||||||
let pendingChars = 0;
|
let pendingChars = 0;
|
||||||
@ -37,24 +18,7 @@ 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) {
|
logListener = listener;
|
||||||
logListeners.delete(legacyLogListener);
|
|
||||||
}
|
|
||||||
legacyLogListener = listener;
|
|
||||||
if (listener) {
|
|
||||||
logListeners.add(listener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addLogListener(listener: LogListener): void {
|
|
||||||
logListeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeLogListener(listener: LogListener): void {
|
|
||||||
logListeners.delete(listener);
|
|
||||||
if (legacyLogListener === listener) {
|
|
||||||
legacyLogListener = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureLogger(baseDir: string): void {
|
export function configureLogger(baseDir: string): void {
|
||||||
@ -87,6 +51,7 @@ function writeStderr(text: string): void {
|
|||||||
try {
|
try {
|
||||||
process.stderr.write(text);
|
process.stderr.write(text);
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore stderr failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,9 +117,11 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore - file may not exist yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,6 +141,7 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
|||||||
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 {
|
||||||
|
// ignore - file may not exist yet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,14 +151,7 @@ async function flushAsync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushInFlight = true;
|
flushInFlight = true;
|
||||||
// Move (not copy) the pending lines out and take ownership. A concurrent write()
|
const linesSnapshot = pendingLines.slice();
|
||||||
// during the await below pushes new lines AND can trim the 1MB cap from the FRONT
|
|
||||||
// of pendingLines; the old count-based removal (pendingLines.slice(snapshot.length))
|
|
||||||
// then sliced off the wrong lines and dropped unwritten ones. Resetting the buffer
|
|
||||||
// here means await-time writes queue independently and nothing desyncs.
|
|
||||||
const linesSnapshot = pendingLines;
|
|
||||||
pendingLines = [];
|
|
||||||
pendingChars = 0;
|
|
||||||
const chunk = linesSnapshot.join("");
|
const chunk = linesSnapshot.join("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -207,19 +168,9 @@ async function flushAsync(): Promise<void> {
|
|||||||
} 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
|
pendingLines = pendingLines.slice(linesSnapshot.length);
|
||||||
// during the await (preserve order), then re-apply the buffer cap so a
|
pendingChars = Math.max(0, pendingChars - chunk.length);
|
||||||
// persistent write failure cannot grow the buffer without bound.
|
|
||||||
pendingLines = linesSnapshot.concat(pendingLines);
|
|
||||||
pendingChars += chunk.length;
|
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
|
||||||
const removed = pendingLines.shift();
|
|
||||||
if (!removed) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
pendingChars = Math.max(0, pendingChars - removed.length);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
flushInFlight = false;
|
flushInFlight = false;
|
||||||
@ -238,21 +189,14 @@ function ensureExitHook(): void {
|
|||||||
process.once("exit", flushSyncPending);
|
process.once("exit", flushSyncPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void {
|
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
ensureExitHook();
|
ensureExitHook();
|
||||||
const ts = logTimestamp();
|
const line = `${new Date().toISOString()} [${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
|
if (logListener) {
|
||||||
// "what failed recently" is answerable even after the file rotates.
|
try { logListener(line); } catch { /* ignore */ }
|
||||||
if (level === "ERROR" || level === "WARN") {
|
|
||||||
recordRecentError(level, message, ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
|
||||||
try { listener(line); } catch { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
@ -271,9 +215,6 @@ function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
|
|
||||||
// (no formatting, no allocation) in the normal/production path.
|
|
||||||
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
|
|
||||||
info: (msg: string): void => write("INFO", msg),
|
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)
|
||||||
|
|||||||
293
src/main/main.ts
293
src/main/main.ts
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||||
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
|
import { AddLinksPayload, AppSettings, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { getLogFilePath, logger } from "./logger";
|
import { getLogFilePath, logger } from "./logger";
|
||||||
@ -9,6 +9,7 @@ import { APP_NAME } from "./constants";
|
|||||||
import { extractHttpLinksFromText } from "./utils";
|
import { extractHttpLinksFromText } from "./utils";
|
||||||
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
|
||||||
|
|
||||||
|
/* ── IPC validation helpers ────────────────────────────────────── */
|
||||||
function validateString(value: unknown, name: string): string {
|
function validateString(value: unknown, name: string): string {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
throw new Error(`${name} muss ein String sein`);
|
throw new Error(`${name} muss ein String sein`);
|
||||||
@ -25,17 +26,6 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
|
|||||||
|
|
||||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
|
||||||
"realdebrid",
|
|
||||||
"megadebrid-api",
|
|
||||||
"megadebrid-web",
|
|
||||||
"bestdebrid",
|
|
||||||
"alldebrid",
|
|
||||||
"ddownload",
|
|
||||||
"onefichier",
|
|
||||||
"debridlink",
|
|
||||||
"linksnappy"
|
|
||||||
]);
|
|
||||||
function validateStringArray(value: unknown, name: string): string[] {
|
function validateStringArray(value: unknown, name: string): string[] {
|
||||||
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
if (!Array.isArray(value) || !value.every(v => typeof v === "string")) {
|
||||||
throw new Error(`${name} muss ein String-Array sein`);
|
throw new Error(`${name} muss ein String-Array sein`);
|
||||||
@ -43,23 +33,19 @@ function validateStringArray(value: unknown, name: string): string[] {
|
|||||||
return value as string[];
|
return value as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Single Instance Lock ───────────────────────────────────────── */
|
||||||
const gotLock = app.requestSingleInstanceLock();
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
if (!gotLock) {
|
if (!gotLock) {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Unhandled error protection ─────────────────────────────────── */
|
||||||
process.on("uncaughtException", (error) => {
|
process.on("uncaughtException", (error) => {
|
||||||
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
|
||||||
});
|
});
|
||||||
process.on("unhandledRejection", (reason) => {
|
process.on("unhandledRejection", (reason) => {
|
||||||
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason);
|
logger.error(`Unhandled Rejection: ${String(reason)}`);
|
||||||
logger.error(`Unhandled Rejection: ${detail}`);
|
|
||||||
});
|
|
||||||
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
|
|
||||||
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
|
|
||||||
process.on("warning", (warning) => {
|
|
||||||
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
@ -77,8 +63,8 @@ function isDevMode(): boolean {
|
|||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(): BrowserWindow {
|
||||||
const window = new BrowserWindow({
|
const window = new BrowserWindow({
|
||||||
width: 1920,
|
width: 1440,
|
||||||
height: 1080,
|
height: 940,
|
||||||
minWidth: 1120,
|
minWidth: 1120,
|
||||||
minHeight: 760,
|
minHeight: 760,
|
||||||
backgroundColor: "#070b14",
|
backgroundColor: "#070b14",
|
||||||
@ -97,7 +83,7 @@ function createWindow(): BrowserWindow {
|
|||||||
responseHeaders: {
|
responseHeaders: {
|
||||||
...details.responseHeaders,
|
...details.responseHeaders,
|
||||||
"Content-Security-Policy": [
|
"Content-Security-Policy": [
|
||||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to https://debrid-link.com"
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.real-debrid.com https://codeberg.org https://bestdebrid.com https://api.alldebrid.com https://www.mega-debrid.eu https://git.24-music.de https://ddownload.com https://ddl.to"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -116,23 +102,6 @@ function createWindow(): BrowserWindow {
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendererReloadTimes: number[] = [];
|
|
||||||
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
|
||||||
const RENDERER_RELOAD_MAX = 3;
|
|
||||||
|
|
||||||
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
|
|
||||||
// after a few crashes in a short window so a reproducible crash can't spin into a
|
|
||||||
// reload loop that pegs an unattended server.
|
|
||||||
function allowRendererReload(): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
|
|
||||||
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
rendererReloadTimes.push(now);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
function bindMainWindowLifecycle(window: BrowserWindow): void {
|
||||||
window.on("close", (event) => {
|
window.on("close", (event) => {
|
||||||
const settings = controller.getSettings();
|
const settings = controller.getSettings();
|
||||||
@ -147,33 +116,6 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.webContents.on("render-process-gone", (_event, details) => {
|
|
||||||
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
|
|
||||||
if (details.reason === "clean-exit" || window.isDestroyed()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (allowRendererReload()) {
|
|
||||||
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
|
|
||||||
try {
|
|
||||||
window.webContents.reload();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
|
|
||||||
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
|
|
||||||
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
|
|
||||||
window.webContents.on("unresponsive", () => {
|
|
||||||
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
|
|
||||||
});
|
|
||||||
window.webContents.on("responsive", () => {
|
|
||||||
logger.info("Renderer wieder reaktionsfähig (responsive)");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTray(): void {
|
function createTray(): void {
|
||||||
@ -183,8 +125,7 @@ function createTray(): void {
|
|||||||
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
|
||||||
try {
|
try {
|
||||||
tray = new Tray(iconPath);
|
tray = new Tray(iconPath);
|
||||||
} catch (error) {
|
} catch {
|
||||||
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tray.setToolTip(APP_NAME);
|
tray.setToolTip(APP_NAME);
|
||||||
@ -305,7 +246,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (result.started) {
|
if (result.started) {
|
||||||
updateQuitTimer = setTimeout(() => {
|
updateQuitTimer = setTimeout(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
}, 5000);
|
}, 900);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
@ -326,6 +267,7 @@ function registerIpcHandlers(): void {
|
|||||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
updateTray();
|
updateTray();
|
||||||
|
// Manage scheduled-start timer
|
||||||
if (scheduledStartTimer !== null) {
|
if (scheduledStartTimer !== null) {
|
||||||
clearTimeout(scheduledStartTimer);
|
clearTimeout(scheduledStartTimer);
|
||||||
scheduledStartTimer = null;
|
scheduledStartTimer = null;
|
||||||
@ -334,6 +276,7 @@ function registerIpcHandlers(): void {
|
|||||||
if (schedMs > 0) {
|
if (schedMs > 0) {
|
||||||
const delay = schedMs - Date.now();
|
const delay = schedMs - Date.now();
|
||||||
if (delay <= 0) {
|
if (delay <= 0) {
|
||||||
|
// Time already passed — start immediately and clear setting
|
||||||
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
|
||||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
||||||
} else {
|
} else {
|
||||||
@ -346,20 +289,6 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
|
|
||||||
const validatedProvider = validateString(provider, "provider") as DebridProvider;
|
|
||||||
if (!RESETTABLE_PROVIDER_KEYS.has(validatedProvider)) {
|
|
||||||
throw new Error("provider ist ungültig");
|
|
||||||
}
|
|
||||||
return controller.resetProviderDailyUsage(validatedProvider);
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, (_event: IpcMainInvokeEvent, keyId: string) => {
|
|
||||||
const validatedKeyId = validateString(keyId, "keyId").trim();
|
|
||||||
if (!validatedKeyId) {
|
|
||||||
throw new Error("keyId ist ungültig");
|
|
||||||
}
|
|
||||||
return controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId);
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
ipcMain.handle(IPC_CHANNELS.ADD_LINKS, (_event: IpcMainInvokeEvent, payload: AddLinksPayload) => {
|
||||||
validatePlainObject(payload ?? {}, "payload");
|
validatePlainObject(payload ?? {}, "payload");
|
||||||
validateString(payload?.rawText, "rawText");
|
validateString(payload?.rawText, "rawText");
|
||||||
@ -386,14 +315,7 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.resolveStartConflict(packageId, policy);
|
return controller.resolveStartConflict(packageId, policy);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||||
ipcMain.handle(IPC_CHANNELS.START, () => {
|
ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
|
||||||
if (scheduledStartTimer !== null) {
|
|
||||||
clearTimeout(scheduledStartTimer);
|
|
||||||
scheduledStartTimer = null;
|
|
||||||
controller.updateSettings({ scheduledStartEpochMs: 0 });
|
|
||||||
}
|
|
||||||
return controller.start();
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||||
validateStringArray(packageIds ?? [], "packageIds");
|
validateStringArray(packageIds ?? [], "packageIds");
|
||||||
return controller.startPackages(packageIds ?? []);
|
return controller.startPackages(packageIds ?? []);
|
||||||
@ -428,40 +350,6 @@ function registerIpcHandlers(): void {
|
|||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
return controller.togglePackage(packageId);
|
return controller.togglePackage(packageId);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, async (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
|
||||||
const validPackageIds = validateStringArray(packageIds ?? [], "packageIds");
|
|
||||||
const exported = controller.exportPackageSelection(validPackageIds);
|
|
||||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
|
||||||
return { saved: false, packageCount: 0, linkCount: 0 };
|
|
||||||
}
|
|
||||||
const options = {
|
|
||||||
defaultPath: exported.defaultFileName,
|
|
||||||
filters: [{ name: "Link Export", extensions: ["txt"] }]
|
|
||||||
};
|
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
|
|
||||||
}
|
|
||||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
|
||||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_ITEM_SELECTION, async (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
|
||||||
const validItemIds = validateStringArray(itemIds ?? [], "itemIds");
|
|
||||||
const exported = controller.exportItemSelection(validItemIds);
|
|
||||||
if (exported.packageCount === 0 || exported.linkCount === 0) {
|
|
||||||
return { saved: false, packageCount: 0, linkCount: 0 };
|
|
||||||
}
|
|
||||||
const options = {
|
|
||||||
defaultPath: exported.defaultFileName,
|
|
||||||
filters: [{ name: "Link Export", extensions: ["txt"] }]
|
|
||||||
};
|
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return { saved: false, packageCount: exported.packageCount, linkCount: exported.linkCount };
|
|
||||||
}
|
|
||||||
await fs.promises.writeFile(result.filePath, exported.text, "utf8");
|
|
||||||
return { saved: true, packageCount: exported.packageCount, linkCount: exported.linkCount, filePath: result.filePath };
|
|
||||||
});
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
|
ipcMain.handle(IPC_CHANNELS.RETRY_EXTRACTION, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||||
validateString(packageId, "packageId");
|
validateString(packageId, "packageId");
|
||||||
return controller.retryExtraction(packageId);
|
return controller.retryExtraction(packageId);
|
||||||
@ -543,8 +431,6 @@ function registerIpcHandlers(): void {
|
|||||||
return result.canceled ? [] : result.filePaths;
|
return result.canceled ? [] : result.filePaths;
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_SESSION_STATS, () => controller.resetSessionStats());
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESET_DOWNLOAD_STATS, () => controller.resetDownloadStats());
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
||||||
app.relaunch();
|
app.relaunch();
|
||||||
@ -557,51 +443,23 @@ function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
ipcMain.handle(IPC_CHANNELS.EXPORT_BACKUP, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.mdd`,
|
defaultPath: `mdd-backup-${new Date().toISOString().slice(0, 10)}.json`,
|
||||||
filters: [{ name: "MDD Backup", extensions: ["mdd"] }]
|
filters: [{ name: "Backup", extensions: ["json"] }]
|
||||||
};
|
};
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
||||||
if (result.canceled || !result.filePath) {
|
if (result.canceled || !result.filePath) {
|
||||||
return { saved: false };
|
return { saved: false };
|
||||||
}
|
}
|
||||||
const encrypted = controller.exportBackup();
|
const json = controller.exportBackup();
|
||||||
await fs.promises.writeFile(result.filePath, encrypted);
|
await fs.promises.writeFile(result.filePath, json, "utf8");
|
||||||
return { saved: true };
|
return { saved: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE, async () => {
|
|
||||||
const options = {
|
|
||||||
defaultPath: controller.getSupportBundleDefaultFileName(),
|
|
||||||
filters: [{ name: "Support Bundle", extensions: ["zip"] }]
|
|
||||||
};
|
|
||||||
const result = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options);
|
|
||||||
if (result.canceled || !result.filePath) {
|
|
||||||
return { saved: false };
|
|
||||||
}
|
|
||||||
const exported = controller.exportSupportBundle();
|
|
||||||
await fs.promises.writeFile(result.filePath, exported.buffer);
|
|
||||||
return { saved: true, filePath: result.filePath };
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_LOG, async () => {
|
||||||
const logPath = getLogFilePath();
|
const logPath = getLogFilePath();
|
||||||
await shell.openPath(logPath);
|
await shell.openPath(logPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_AUDIT_LOG, async () => {
|
|
||||||
const logPath = controller.getAuditLogPath();
|
|
||||||
if (logPath) {
|
|
||||||
await shell.openPath(logPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_RENAME_LOG, async () => {
|
|
||||||
const logPath = controller.getRenameLogPath();
|
|
||||||
if (logPath) {
|
|
||||||
await shell.openPath(logPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_SESSION_LOG, async () => {
|
||||||
const logPath = controller.getSessionLogPath();
|
const logPath = controller.getSessionLogPath();
|
||||||
if (logPath) {
|
if (logPath) {
|
||||||
@ -609,51 +467,6 @@ function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_TRACE_LOG, async () => {
|
|
||||||
const logPath = controller.getTraceLogPath();
|
|
||||||
if (logPath) {
|
|
||||||
await shell.openPath(logPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_PACKAGE_LOG, async (_event: IpcMainInvokeEvent, packageId: string) => {
|
|
||||||
validateString(packageId, "packageId");
|
|
||||||
const logPath = controller.getPackageLogPath(packageId);
|
|
||||||
if (logPath) {
|
|
||||||
await shell.openPath(logPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck());
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig());
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => {
|
|
||||||
if (typeof enabled !== "boolean") {
|
|
||||||
throw new Error("enabled muss ein Boolean sein");
|
|
||||||
}
|
|
||||||
if (note !== undefined) {
|
|
||||||
validateString(note, "note");
|
|
||||||
}
|
|
||||||
if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) {
|
|
||||||
throw new Error("durationMinutes muss eine positive Zahl sein");
|
|
||||||
}
|
|
||||||
return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => {
|
|
||||||
const rotated = controller.rotateDebugToken();
|
|
||||||
return { path: rotated.path };
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
|
||||||
validateString(itemId, "itemId");
|
|
||||||
const logPath = controller.getItemLogPath(itemId);
|
|
||||||
if (logPath) {
|
|
||||||
await shell.openPath(logPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN, async () => {
|
||||||
await controller.openRealDebridLoginWindow();
|
await controller.openRealDebridLoginWindow();
|
||||||
});
|
});
|
||||||
@ -681,24 +494,11 @@ function registerIpcHandlers(): void {
|
|||||||
return controller.getAllDebridHostInfo();
|
return controller.getAllDebridHostInfo();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS, async () => {
|
|
||||||
return controller.getDebridLinkHostLimits();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS, async () => {
|
|
||||||
return controller.checkDebridAccounts();
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, async (_event, login: string, password: string) => {
|
|
||||||
return controller.checkSingleMegaDebridAccount(String(login || ""), String(password || ""));
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
ipcMain.handle(IPC_CHANNELS.IMPORT_BACKUP, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openFile"] as Array<"openFile">,
|
properties: ["openFile"] as Array<"openFile">,
|
||||||
filters: [
|
filters: [
|
||||||
{ name: "MDD Backup", extensions: ["mdd"] },
|
{ name: "Backup", extensions: ["json"] },
|
||||||
{ name: "Legacy Backup (JSON)", extensions: ["json"] },
|
|
||||||
{ name: "Alle Dateien", extensions: ["*"] }
|
{ name: "Alle Dateien", extensions: ["*"] }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@ -712,26 +512,8 @@ function registerIpcHandlers(): void {
|
|||||||
if (stat.size > BACKUP_MAX_BYTES) {
|
if (stat.size > BACKUP_MAX_BYTES) {
|
||||||
return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
|
return { restored: false, message: `Backup-Datei zu groß (max 50 MB, Datei hat ${(stat.size / 1024 / 1024).toFixed(1)} MB)` };
|
||||||
}
|
}
|
||||||
const data = await fs.promises.readFile(filePath);
|
const json = await fs.promises.readFile(filePath, "utf8");
|
||||||
const importResult = controller.importBackup(data);
|
return controller.importBackup(json);
|
||||||
// Only a full restore (queue swapped) needs the auto-relaunch. A settings-
|
|
||||||
// only import applied live — relaunching would be pointless and would drop
|
|
||||||
// the running queue.
|
|
||||||
if (importResult.restored && importResult.relaunch) {
|
|
||||||
setTimeout(() => {
|
|
||||||
app.relaunch();
|
|
||||||
app.quit();
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
return importResult;
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
|
|
||||||
try {
|
|
||||||
logger.error(formatRendererErrorReport(rawReport));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.onState = (snapshot) => {
|
controller.onState = (snapshot) => {
|
||||||
@ -742,41 +524,6 @@ function registerIpcHandlers(): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRendererErrorReport(rawReport: unknown): string {
|
|
||||||
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
|
|
||||||
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
||||||
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
|
|
||||||
const kind = str(report.kind) || "error";
|
|
||||||
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
|
|
||||||
const source = str(report.source);
|
|
||||||
const line = num(report.line);
|
|
||||||
const column = num(report.column);
|
|
||||||
const stack = str(report.stack).slice(0, 4000);
|
|
||||||
const componentStack = str(report.componentStack).slice(0, 4000);
|
|
||||||
|
|
||||||
const parts: string[] = [`[Renderer:${kind}] ${message}`];
|
|
||||||
if (source) {
|
|
||||||
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
|
|
||||||
}
|
|
||||||
if (stack) {
|
|
||||||
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
|
||||||
}
|
|
||||||
if (componentStack) {
|
|
||||||
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
|
|
||||||
}
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
app.on("child-process-gone", (_event, details) => {
|
|
||||||
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
|
|
||||||
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
|
|
||||||
if (killed) {
|
|
||||||
logger.error(line);
|
|
||||||
} else {
|
|
||||||
logger.warn(line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on("second-instance", () => {
|
app.on("second-instance", () => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
if (mainWindow.isMinimized()) {
|
if (mainWindow.isMinimized()) {
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
|
||||||
const MEGA_API_TIMEOUT_MS = 12_000;
|
|
||||||
|
|
||||||
export interface MegaFileInfo {
|
|
||||||
name: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NEW_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/file\/([A-Za-z0-9_-]+)#([A-Za-z0-9_-]+)/i;
|
|
||||||
const LEGACY_FORMAT_RE = /^https?:\/\/mega\.(?:nz|co\.nz)\/#!([A-Za-z0-9_-]+)!([A-Za-z0-9_-]+)/i;
|
|
||||||
|
|
||||||
export function isMegaFileUrl(url: string): boolean {
|
|
||||||
const s = String(url || "").trim();
|
|
||||||
return NEW_FORMAT_RE.test(s) || LEGACY_FORMAT_RE.test(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64UrlDecode(s: string): Buffer | null {
|
|
||||||
let b64 = String(s || "").trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
||||||
while (b64.length % 4 !== 0) b64 += "=";
|
|
||||||
try {
|
|
||||||
return Buffer.from(b64, "base64");
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedMegaLink {
|
|
||||||
id: string;
|
|
||||||
rawKey: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
|
||||||
const s = String(url || "").trim();
|
|
||||||
const m = NEW_FORMAT_RE.exec(s) || LEGACY_FORMAT_RE.exec(s);
|
|
||||||
if (!m) return null;
|
|
||||||
const id = m[1];
|
|
||||||
const rawKey = base64UrlDecode(m[2]);
|
|
||||||
if (!rawKey || rawKey.length !== 32) return null;
|
|
||||||
return { id, rawKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptMegaAttributes(encrypted: Buffer, aesKey: Buffer): Record<string, unknown> | null {
|
|
||||||
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length % 16 !== 0) return null;
|
|
||||||
if (!Buffer.isBuffer(aesKey) || aesKey.length !== 16) return null;
|
|
||||||
let plain: Buffer;
|
|
||||||
try {
|
|
||||||
const decipher = crypto.createDecipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
|
||||||
decipher.setAutoPadding(false);
|
|
||||||
plain = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const text = plain.toString("utf8").replace(/\0+$/, "").trim();
|
|
||||||
if (!text.startsWith("MEGA{")) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(text.slice(4));
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeoutSignal(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort("mega-api-timeout"), timeoutMs);
|
|
||||||
if (parent) {
|
|
||||||
if (parent.aborted) {
|
|
||||||
controller.abort(parent.reason);
|
|
||||||
} else {
|
|
||||||
parent.addEventListener("abort", () => controller.abort(parent.reason), { once: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
controller.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
|
|
||||||
return controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveMegaFilename(
|
|
||||||
url: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<MegaFileInfo | null> {
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
if (!parsed) return null;
|
|
||||||
const aesKey = parsed.rawKey.subarray(0, 16);
|
|
||||||
|
|
||||||
const apiUrl = `${MEGA_API_BASE}?id=${Math.floor(Math.random() * 1e9)}`;
|
|
||||||
const body = JSON.stringify([{ a: "g", g: 1, p: parsed.id }]);
|
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
try {
|
|
||||||
response = await fetch(apiUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body,
|
|
||||||
signal: withTimeoutSignal(signal, MEGA_API_TIMEOUT_MS)
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!response.ok) return null;
|
|
||||||
|
|
||||||
let payload: unknown;
|
|
||||||
try {
|
|
||||||
payload = await response.json();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof payload === "number") return null;
|
|
||||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
|
||||||
|
|
||||||
const first = payload[0];
|
|
||||||
if (typeof first === "number") return null;
|
|
||||||
if (!first || typeof first !== "object") return null;
|
|
||||||
|
|
||||||
const info = first as { s?: unknown; at?: unknown; e?: unknown };
|
|
||||||
if (typeof info.e === "number" && info.e !== 0) return null;
|
|
||||||
|
|
||||||
const size = typeof info.s === "number" && info.s > 0 ? info.s : 0;
|
|
||||||
if (typeof info.at !== "string" || !info.at.trim()) return null;
|
|
||||||
|
|
||||||
const encryptedAttrs = base64UrlDecode(info.at);
|
|
||||||
if (!encryptedAttrs) return null;
|
|
||||||
|
|
||||||
const attrs = decryptMegaAttributes(encryptedAttrs, aesKey);
|
|
||||||
if (!attrs || typeof attrs.n !== "string" || !attrs.n.trim()) return null;
|
|
||||||
|
|
||||||
return { name: attrs.n.trim(), size };
|
|
||||||
}
|
|
||||||
@ -16,8 +16,6 @@ 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;
|
|
||||||
|
|
||||||
function normalizeLink(link: string): string {
|
function normalizeLink(link: string): string {
|
||||||
return link.trim().toLowerCase();
|
return link.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
@ -221,38 +219,43 @@ export class MegaWebFallback {
|
|||||||
|
|
||||||
private getCredentials: () => MegaCredentials;
|
private getCredentials: () => MegaCredentials;
|
||||||
|
|
||||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
private cookie = "";
|
||||||
|
|
||||||
|
private cookieSetAt = 0;
|
||||||
|
|
||||||
public constructor(getCredentials: () => MegaCredentials) {
|
public constructor(getCredentials: () => MegaCredentials) {
|
||||||
this.getCredentials = getCredentials;
|
this.getCredentials = getCredentials;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unrestrict(
|
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
|
||||||
link: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
account?: { login: string; password: string }
|
|
||||||
): Promise<UnrestrictedLink | null> {
|
|
||||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||||
return this.runExclusive(async () => {
|
return this.runExclusive(async () => {
|
||||||
throwIfAborted(overallSignal);
|
throwIfAborted(overallSignal);
|
||||||
const creds = (account && account.login.trim() && account.password.trim())
|
const creds = this.getCredentials();
|
||||||
? account
|
|
||||||
: 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();
|
|
||||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
|
||||||
|
|
||||||
let generated = await this.generate(link, cookie, overallSignal);
|
if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
|
||||||
|
await this.login(creds.login, creds.password, overallSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const generated = await this.generate(link, overallSignal);
|
||||||
if (!generated) {
|
if (!generated) {
|
||||||
this.sessions.delete(key);
|
this.cookie = "";
|
||||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
await this.login(creds.login, creds.password, overallSignal);
|
||||||
generated = await this.generate(link, cookie, overallSignal);
|
const retry = await this.generate(link, overallSignal);
|
||||||
if (!generated) {
|
if (!retry) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
directUrl: retry.directUrl,
|
||||||
|
fileName: retry.fileName || filenameFromUrl(link),
|
||||||
|
fileSize: null,
|
||||||
|
retriesUsed: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
directUrl: generated.directUrl,
|
directUrl: generated.directUrl,
|
||||||
fileName: generated.fileName || filenameFromUrl(link),
|
fileName: generated.fileName || filenameFromUrl(link),
|
||||||
@ -262,18 +265,9 @@ export class MegaWebFallback {
|
|||||||
}, overallSignal);
|
}, overallSignal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
|
||||||
const existing = this.sessions.get(key);
|
|
||||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
|
||||||
return existing.cookie;
|
|
||||||
}
|
|
||||||
const cookie = await this.login(login, password, signal);
|
|
||||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
|
||||||
return cookie;
|
|
||||||
}
|
|
||||||
|
|
||||||
public invalidateSession(): void {
|
public invalidateSession(): void {
|
||||||
this.sessions.clear();
|
this.cookie = "";
|
||||||
|
this.cookieSetAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
@ -292,7 +286,7 @@ export class MegaWebFallback {
|
|||||||
return raceWithAbort(run, signal);
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
private async login(login: string, password: string, signal?: AbortSignal): Promise<void> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
const response = await fetch(LOGIN_URL, {
|
const response = await fetch(LOGIN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -329,17 +323,18 @@ export class MegaWebFallback {
|
|||||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||||
}
|
}
|
||||||
|
|
||||||
return cookie;
|
this.cookie = cookie;
|
||||||
|
this.cookieSetAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
private async generate(link: 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: this.cookie,
|
||||||
Referer: DEBRID_REFERER
|
Referer: DEBRID_REFERER
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
@ -352,17 +347,13 @@ export class MegaWebFallback {
|
|||||||
|
|
||||||
const html = await page.text();
|
const html = await page.text();
|
||||||
|
|
||||||
|
// Check for permanent hoster errors before looking for debrid codes
|
||||||
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));
|
|
||||||
if (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;
|
||||||
@ -375,7 +366,7 @@ export class MegaWebFallback {
|
|||||||
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: this.cookie,
|
||||||
Referer: DEBRID_REFERER
|
Referer: DEBRID_REFERER
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
@ -404,10 +395,6 @@ export class MegaWebFallback {
|
|||||||
await sleepWithSignal(1200, signal);
|
await sleepWithSignal(1200, signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
||||||
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
|
||||||
throw new Error(`Mega-Web: ${serverMsg}`);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -428,7 +415,7 @@ export class MegaWebFallback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.sessions.clear();
|
this.cookie = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,230 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
|
|
||||||
const PACKAGE_LOG_FLUSH_INTERVAL_MS = 200;
|
|
||||||
const PACKAGE_LOG_RETENTION_DAYS = 30;
|
|
||||||
|
|
||||||
type PackageLogLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
export interface PackageLogMeta {
|
|
||||||
packageId: string;
|
|
||||||
name: string;
|
|
||||||
outputDir: string;
|
|
||||||
extractDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let packageLogsDir: string | null = null;
|
|
||||||
const knownLogPaths = new Map<string, string>();
|
|
||||||
const pendingLinesByPackage = new Map<string, string[]>();
|
|
||||||
const initializedThisProcess = new Set<string>();
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
function normalizePackageId(packageId: string): string {
|
|
||||||
const trimmed = String(packageId || "").trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const safePrefix = trimmed
|
|
||||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
|
||||||
.replace(/_+/g, "_")
|
|
||||||
.slice(0, 64)
|
|
||||||
.replace(/^_+|_+$/g, "");
|
|
||||||
const hash = crypto.createHash("sha1").update(trimmed).digest("hex").slice(0, 12);
|
|
||||||
return `${safePrefix || "pkg"}_${hash}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPackageLogFilePathFromNormalized(normalized: string): string | null {
|
|
||||||
if (!normalized || !packageLogsDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const existing = knownLogPaths.get(normalized);
|
|
||||||
if (existing) {
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
const logPath = path.join(packageLogsDir, `package_${normalized}.txt`);
|
|
||||||
knownLogPaths.set(normalized, logPath);
|
|
||||||
return logPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPackageLogFilePath(packageId: string): string | null {
|
|
||||||
return getPackageLogFilePathFromNormalized(normalizePackageId(packageId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPending(): void {
|
|
||||||
for (const [packageId, lines] of pendingLinesByPackage.entries()) {
|
|
||||||
if (lines.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
|
||||||
if (!logPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const chunk = lines.join("");
|
|
||||||
pendingLinesByPackage.set(packageId, []);
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flushTimer = setTimeout(() => {
|
|
||||||
flushTimer = null;
|
|
||||||
flushPending();
|
|
||||||
}, PACKAGE_LOG_FLUSH_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const files = await fs.promises.readdir(dir);
|
|
||||||
const cutoff = Date.now() - PACKAGE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.startsWith("package_") || !file.endsWith(".txt")) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const filePath = path.join(dir, file);
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.stat(filePath);
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
await fs.promises.unlink(filePath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendLine(packageId: string, line: string): void {
|
|
||||||
const normalized = normalizePackageId(packageId);
|
|
||||||
if (!normalized) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lines = pendingLinesByPackage.get(normalized) || [];
|
|
||||||
lines.push(line);
|
|
||||||
pendingLinesByPackage.set(normalized, lines);
|
|
||||||
scheduleFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initPackageLogs(baseDir: string): void {
|
|
||||||
packageLogsDir = path.join(baseDir, "package-logs");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(packageLogsDir, { recursive: true });
|
|
||||||
} catch {
|
|
||||||
packageLogsDir = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void cleanupOldPackageLogs(packageLogsDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensurePackageLog(meta: PackageLogMeta): string | null {
|
|
||||||
const normalizedPackageId = normalizePackageId(meta.packageId);
|
|
||||||
const logPath = getPackageLogFilePath(meta.packageId);
|
|
||||||
if (!logPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
||||||
if (!fs.existsSync(logPath)) {
|
|
||||||
fs.writeFileSync(logPath, "", "utf8");
|
|
||||||
}
|
|
||||||
if (!initializedThisProcess.has(normalizedPackageId)) {
|
|
||||||
initializedThisProcess.add(normalizedPackageId);
|
|
||||||
const startedAt = logTimestamp();
|
|
||||||
fs.appendFileSync(
|
|
||||||
logPath,
|
|
||||||
`=== Paket-Log Start: ${startedAt} | packageId=${sanitizeFieldValue(String(meta.packageId || ""))} | logKey=${normalizedPackageId} | name=${sanitizeFieldValue(meta.name)} ===\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
fs.appendFileSync(
|
|
||||||
logPath,
|
|
||||||
`${logTimestamp()} [INFO] Paket-Kontext initialisiert${formatFields({
|
|
||||||
name: meta.name,
|
|
||||||
outputDir: meta.outputDir,
|
|
||||||
extractDir: meta.extractDir
|
|
||||||
})}\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return logPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logPackageEvent(
|
|
||||||
packageId: string,
|
|
||||||
level: PackageLogLevel,
|
|
||||||
message: string,
|
|
||||||
fields?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
const logPath = getPackageLogFilePath(packageId);
|
|
||||||
if (!logPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const line = `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`;
|
|
||||||
appendLine(packageId, line);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPackageLogPath(packageId: string): string | null {
|
|
||||||
const logPath = getPackageLogFilePath(packageId);
|
|
||||||
if (!logPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(logPath) ? logPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownPackageLogs(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
flushPending();
|
|
||||||
for (const packageId of knownLogPaths.keys()) {
|
|
||||||
const logPath = getPackageLogFilePathFromNormalized(packageId);
|
|
||||||
if (!logPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingLinesByPackage.clear();
|
|
||||||
knownLogPaths.clear();
|
|
||||||
initializedThisProcess.clear();
|
|
||||||
packageLogsDir = null;
|
|
||||||
}
|
|
||||||
@ -79,31 +79,6 @@ function looksLikeHtmlResponse(text: string): boolean {
|
|||||||
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 {
|
|
||||||
const normalized = String(html || "");
|
|
||||||
if (!normalized.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patterns = [
|
|
||||||
/private_token['"]\]\[0\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
|
||||||
/getElementsByName\(\s*['"]private_token['"]\s*\)\s*\[\s*0\s*\]\.value\s*=\s*['"]([^'"]+)['"]/i,
|
|
||||||
/querySelector(?:All)?\(\s*['"][^'"]*private_token[^'"]*['"]\s*\)(?:\s*\[\s*0\s*\])?\.value\s*=\s*['"]([^'"]+)['"]/i,
|
|
||||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
|
||||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = normalized.match(pattern);
|
|
||||||
const token = match?.[1]?.trim();
|
|
||||||
if (token) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RealDebridWebFallback {
|
export class RealDebridWebFallback {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
@ -144,7 +119,6 @@ export class RealDebridWebFallback {
|
|||||||
}
|
}
|
||||||
window.show();
|
window.show();
|
||||||
window.focus();
|
window.focus();
|
||||||
void this.primeTokenFromWindow(window);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearSessions(): Promise<void> {
|
public async clearSessions(): Promise<void> {
|
||||||
@ -158,10 +132,12 @@ export class RealDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,7 +161,7 @@ export class RealDebridWebFallback {
|
|||||||
|
|
||||||
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 = 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;
|
||||||
@ -225,15 +201,6 @@ export class RealDebridWebFallback {
|
|||||||
});
|
});
|
||||||
window.setMenuBarVisibility(false);
|
window.setMenuBarVisibility(false);
|
||||||
window.webContents.setUserAgent(RD_USER_AGENT);
|
window.webContents.setUserAgent(RD_USER_AGENT);
|
||||||
const primeFromWindow = (): void => {
|
|
||||||
void this.primeTokenFromWindow(window);
|
|
||||||
};
|
|
||||||
window.webContents.on("did-finish-load", primeFromWindow);
|
|
||||||
window.webContents.on("did-navigate", primeFromWindow);
|
|
||||||
window.webContents.on("did-navigate-in-page", primeFromWindow);
|
|
||||||
window.on("close", () => {
|
|
||||||
void this.primeTokenFromWindow(window);
|
|
||||||
});
|
|
||||||
window.on("closed", () => {
|
window.on("closed", () => {
|
||||||
if (this.loginWindow === window) {
|
if (this.loginWindow === window) {
|
||||||
this.loginWindow = null;
|
this.loginWindow = null;
|
||||||
@ -246,105 +213,14 @@ export class RealDebridWebFallback {
|
|||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberToken(token: string): string {
|
|
||||||
this.cachedToken = token;
|
|
||||||
this.cachedTokenAt = Date.now();
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getActiveLoginWindow(): BrowserWindow | null {
|
|
||||||
const window = this.loginWindow;
|
|
||||||
if (!window || window.isDestroyed()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (this.loginWindowPartition !== this.getPartition()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return window;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async extractApiTokenFromWindow(window: BrowserWindow, signal?: AbortSignal): Promise<string | null> {
|
|
||||||
throwIfAborted(signal);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rawResult = await window.webContents.executeJavaScript(`
|
|
||||||
(async () => {
|
|
||||||
const readTokenFromHtml = (html) => {
|
|
||||||
const text = String(html || "");
|
|
||||||
const patterns = [
|
|
||||||
/private_token['"]\\]\\[0\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
|
||||||
/getElementsByName\\(\\s*['"]private_token['"]\\s*\\)\\s*\\[\\s*0\\s*\\]\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
|
||||||
/querySelector(?:All)?\\(\\s*['"][^'"]*private_token[^'"]*['"]\\s*\\)(?:\\s*\\[\\s*0\\s*\\])?\\.value\\s*=\\s*['"]([^'"]+)['"]/i,
|
|
||||||
/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/i,
|
|
||||||
/value=['"]([^'"]+)['"][^>]*name=['"]private_token['"]/i
|
|
||||||
];
|
|
||||||
for (const pattern of patterns) {
|
|
||||||
const match = text.match(pattern);
|
|
||||||
if (match && match[1]) {
|
|
||||||
return String(match[1]).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const directInput = document.querySelector('input[name="private_token"]');
|
|
||||||
if (directInput instanceof HTMLInputElement && directInput.value.trim()) {
|
|
||||||
return directInput.value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const html = document.documentElement ? document.documentElement.outerHTML : "";
|
|
||||||
const directToken = readTokenFromHtml(html);
|
|
||||||
if (directToken) {
|
|
||||||
return directToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(${JSON.stringify(RD_APITOKEN_URL)}, {
|
|
||||||
credentials: "include",
|
|
||||||
cache: "no-store",
|
|
||||||
headers: {
|
|
||||||
"X-Requested-With": "XMLHttpRequest"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const tokenHtml = await response.text();
|
|
||||||
return readTokenFromHtml(tokenHtml);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`, true);
|
|
||||||
const token = String(rawResult || "").trim();
|
|
||||||
if (token) {
|
|
||||||
return this.rememberToken(token);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async primeTokenFromWindow(window: BrowserWindow): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.extractApiTokenFromWindow(window);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
|
// Return cached token if fresh (max 30 min)
|
||||||
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();
|
|
||||||
if (activeLoginWindow) {
|
|
||||||
const windowToken = await this.extractApiTokenFromWindow(activeLoginWindow, signal);
|
|
||||||
if (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: {
|
||||||
@ -360,9 +236,21 @@ export class RealDebridWebFallback {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = extractPrivateTokenFromHtml(html);
|
// Real-Debrid sets the token via inline JS:
|
||||||
if (token) {
|
// document.querySelectorAll('input[name=private_token]')[0].value = 'TOKEN_HERE';
|
||||||
return this.rememberToken(token);
|
const tokenMatch = html.match(/private_token['"]\]\[0\]\.value\s*=\s*'([^']+)'/);
|
||||||
|
if (tokenMatch && tokenMatch[1]) {
|
||||||
|
this.cachedToken = tokenMatch[1];
|
||||||
|
this.cachedTokenAt = Date.now();
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: look for the token in an input value attribute
|
||||||
|
const inputMatch = html.match(/name=['"]private_token['"][^>]*value=['"]([^'"]+)['"]/);
|
||||||
|
if (inputMatch && inputMatch[1]) {
|
||||||
|
this.cachedToken = inputMatch[1];
|
||||||
|
this.cachedTokenAt = Date.now();
|
||||||
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -394,6 +282,7 @@ export class RealDebridWebFallback {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// Token expired or revoked — invalidate cache
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
this.cachedTokenAt = 0;
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
|
|||||||
@ -10,8 +10,6 @@ export interface UnrestrictedLink {
|
|||||||
retriesUsed: number;
|
retriesUsed: number;
|
||||||
skipTlsVerify?: boolean;
|
skipTlsVerify?: boolean;
|
||||||
sourceLabel?: string;
|
sourceLabel?: string;
|
||||||
sourceAccountId?: string;
|
|
||||||
sourceAccountLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryStatus(status: number): boolean {
|
function shouldRetryStatus(status: number): boolean {
|
||||||
@ -82,6 +80,8 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Check before entering the Promise constructor to avoid a race where the timer
|
||||||
|
// resolves before the aborted check runs (especially when ms=0).
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw new Error("aborted");
|
throw new Error("aborted");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,119 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
type RenameLogLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
const RENAME_LOG_MAX_FILE_BYTES = Number(process.env.RD_RENAME_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
|
||||||
const RENAME_LOG_RETENTION_DAYS = Number(process.env.RD_RENAME_LOG_RETENTION_DAYS || 30);
|
|
||||||
|
|
||||||
let renameLogPath: string | null = null;
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size < RENAME_LOG_MAX_FILE_BYTES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
fs.renameSync(filePath, backup);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(backup);
|
|
||||||
const cutoff = Date.now() - RENAME_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initRenameLog(baseDir: string): void {
|
|
||||||
renameLogPath = path.join(baseDir, "rename.log");
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(path.dirname(renameLogPath), { recursive: true });
|
|
||||||
cleanupOldBackup(renameLogPath);
|
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
rotateIfNeeded(renameLogPath);
|
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
renameLogPath = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logRenameEvent(level: RenameLogLevel, message: string, fields?: Record<string, unknown>): void {
|
|
||||||
if (!renameLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
rotateIfNeeded(renameLogPath);
|
|
||||||
if (!fs.existsSync(renameLogPath)) {
|
|
||||||
fs.writeFileSync(renameLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(
|
|
||||||
renameLogPath,
|
|
||||||
`${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`,
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getRenameLogPath(): string | null {
|
|
||||||
if (!renameLogPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(renameLogPath) ? renameLogPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownRenameLog(): void {
|
|
||||||
if (!renameLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
renameLogPath = null;
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { setLogListener } from "./logger";
|
import { setLogListener } from "./logger";
|
||||||
|
|
||||||
@ -30,6 +29,7 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore write errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,9 +66,11 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore - file may be locked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore - dir may not exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +86,7 @@ export function initSessionLog(baseDir: string): void {
|
|||||||
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 = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
fs.writeFileSync(sessionLogPath, `=== Session gestartet: ${isoTimestamp} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
@ -106,16 +108,19 @@ export function shutdownSessionLog(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush any pending lines
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
|
||||||
const isoTimestamp = logTimestamp();
|
// Write closing line
|
||||||
|
const isoTimestamp = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogListener(null);
|
setLogListener(null);
|
||||||
|
|||||||
@ -1,195 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { AppSettings } from "../shared/types";
|
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|
||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
|
||||||
import { StoragePaths } from "./storage";
|
|
||||||
|
|
||||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
export interface HealthCheckFinding {
|
|
||||||
severity: HealthCheckSeverity;
|
|
||||||
code: string;
|
|
||||||
message: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HealthCheckReport {
|
|
||||||
findings: HealthCheckFinding[];
|
|
||||||
errorCount: number;
|
|
||||||
warnCount: number;
|
|
||||||
infoCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
|
||||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
|
||||||
|
|
||||||
function safeExists(p: string): boolean {
|
|
||||||
try {
|
|
||||||
return fs.existsSync(p);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileSizeBytes(p: string): number {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(p);
|
|
||||||
return stat.size;
|
|
||||||
} catch {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWritable(dir: string): boolean {
|
|
||||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(probe, "x", { encoding: "utf8" });
|
|
||||||
fs.rmSync(probe, { force: true });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFreeDiskSpaceBytes(target: string): number | null {
|
|
||||||
try {
|
|
||||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
|
||||||
if (typeof statfs !== "function") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const result = statfs(target);
|
|
||||||
const bavail = BigInt(result.bavail);
|
|
||||||
const bsize = BigInt(result.bsize);
|
|
||||||
const free = bavail * bsize;
|
|
||||||
if (free > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
||||||
return Number.MAX_SAFE_INTEGER;
|
|
||||||
}
|
|
||||||
return Number(free);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countConfiguredProviders(settings: AppSettings): { count: number; providers: string[] } {
|
|
||||||
const providers: string[] = [];
|
|
||||||
if (settings.token?.trim() || settings.realDebridUseWebLogin) {
|
|
||||||
providers.push("Real-Debrid");
|
|
||||||
}
|
|
||||||
if (settings.allDebridToken?.trim() || settings.allDebridUseWebLogin) {
|
|
||||||
providers.push("AllDebrid");
|
|
||||||
}
|
|
||||||
if (settings.bestToken?.trim() || settings.bestDebridUseWebLogin) {
|
|
||||||
providers.push("BestDebrid");
|
|
||||||
}
|
|
||||||
if (settings.oneFichierApiKey?.trim()) {
|
|
||||||
providers.push("1Fichier");
|
|
||||||
}
|
|
||||||
if (settings.ddownloadLogin?.trim() && settings.ddownloadPassword?.trim()) {
|
|
||||||
providers.push("DDownload");
|
|
||||||
}
|
|
||||||
if (settings.linkSnappyLogin?.trim() && settings.linkSnappyPassword?.trim()) {
|
|
||||||
providers.push("LinkSnappy");
|
|
||||||
}
|
|
||||||
const dlKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
|
||||||
if (dlKeys.length > 0) {
|
|
||||||
providers.push(`Debrid-Link (${dlKeys.length} Key${dlKeys.length === 1 ? "" : "s"})`);
|
|
||||||
}
|
|
||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
|
||||||
const legacyMegaConfigured = Boolean(settings.megaLogin?.trim() && settings.megaPassword?.trim());
|
|
||||||
if (megaAccounts.length > 0) {
|
|
||||||
providers.push(`Mega-Debrid (${megaAccounts.length} Acc)`);
|
|
||||||
} else if (legacyMegaConfigured) {
|
|
||||||
providers.push("Mega-Debrid");
|
|
||||||
}
|
|
||||||
return { count: providers.length, providers };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
|
||||||
const findings: HealthCheckFinding[] = [];
|
|
||||||
|
|
||||||
const outputDir = String(settings.outputDir || "").trim();
|
|
||||||
if (!outputDir) {
|
|
||||||
findings.push({
|
|
||||||
severity: "WARN",
|
|
||||||
code: "outputDir_missing",
|
|
||||||
message: "Kein Download-Ziel-Verzeichnis konfiguriert",
|
|
||||||
hint: "In den Einstellungen unter 'Downloads' einen Ziel-Ordner setzen, sonst koennen keine Downloads starten."
|
|
||||||
});
|
|
||||||
} else if (!safeExists(outputDir)) {
|
|
||||||
findings.push({
|
|
||||||
severity: "WARN",
|
|
||||||
code: "outputDir_not_found",
|
|
||||||
message: `Download-Ziel-Ordner existiert nicht: ${outputDir}`,
|
|
||||||
hint: "Der Ordner wird beim ersten Download automatisch erstellt, sofern der Elternordner existiert und beschreibbar ist."
|
|
||||||
});
|
|
||||||
} else if (!isWritable(outputDir)) {
|
|
||||||
findings.push({
|
|
||||||
severity: "ERROR",
|
|
||||||
code: "outputDir_not_writable",
|
|
||||||
message: `Download-Ziel-Ordner ist NICHT beschreibbar: ${outputDir}`,
|
|
||||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
|
||||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
|
||||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
|
||||||
findings.push({
|
|
||||||
severity: "WARN",
|
|
||||||
code: "low_disk_space",
|
|
||||||
message: `Wenig freier Speicher im Download-Ordner: ~${freeMb} MB verfuegbar (Schwelle ${LOW_DISK_SPACE_BYTES / (1024 * 1024 * 1024)} GB)`,
|
|
||||||
hint: "Groessere Downloads koennen auf halbem Weg fehlschlagen. Vorher Platz schaffen oder anderen Ordner waehlen."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { count, providers } = countConfiguredProviders(settings);
|
|
||||||
if (count === 0) {
|
|
||||||
findings.push({
|
|
||||||
severity: "WARN",
|
|
||||||
code: "no_provider_configured",
|
|
||||||
message: "Kein Debrid-Provider konfiguriert — Downloads werden nicht funktionieren",
|
|
||||||
hint: "In den Einstellungen mindestens einen Provider (Real-Debrid, Mega-Debrid, Debrid-Link, ...) einrichten."
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
findings.push({
|
|
||||||
severity: "INFO",
|
|
||||||
code: "providers_configured",
|
|
||||||
message: `Konfigurierte Provider: ${providers.join(", ")}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (safeExists(storagePaths.sessionFile)) {
|
|
||||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
|
||||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
|
||||||
const sizeMb = Math.round(sizeBytes / (1024 * 1024));
|
|
||||||
findings.push({
|
|
||||||
severity: "WARN",
|
|
||||||
code: "large_state_file",
|
|
||||||
message: `State-Datei ist sehr gross: ${sizeMb} MB (${path.basename(storagePaths.sessionFile)})`,
|
|
||||||
hint: "Alte abgeschlossene Pakete aus der Queue entfernen, damit Startup + Save schneller werden."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!safeExists(storagePaths.baseDir)) {
|
|
||||||
findings.push({
|
|
||||||
severity: "ERROR",
|
|
||||||
code: "baseDir_missing",
|
|
||||||
message: `Runtime-Verzeichnis existiert nicht: ${storagePaths.baseDir}`,
|
|
||||||
hint: "Ohne Runtime-Verzeichnis koennen weder Settings noch Session-State persistiert werden."
|
|
||||||
});
|
|
||||||
} else if (!isWritable(storagePaths.baseDir)) {
|
|
||||||
findings.push({
|
|
||||||
severity: "ERROR",
|
|
||||||
code: "baseDir_not_writable",
|
|
||||||
message: `Runtime-Verzeichnis ist NICHT beschreibbar: ${storagePaths.baseDir}`,
|
|
||||||
hint: "Rechte auf das Runtime-Verzeichnis pruefen (%APPDATA%/Real-Debrid-Downloader/runtime)."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorCount = findings.filter((f) => f.severity === "ERROR").length;
|
|
||||||
const warnCount = findings.filter((f) => f.severity === "WARN").length;
|
|
||||||
const infoCount = findings.filter((f) => f.severity === "INFO").length;
|
|
||||||
return { findings, errorCount, warnCount, infoCount };
|
|
||||||
}
|
|
||||||
@ -1,10 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import fsp from "node:fs/promises";
|
import fsp from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { AppSettings, BandwidthScheduleEntry, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
||||||
import { getMegaDebridAccountIds } from "../shared/mega-debrid-accounts";
|
|
||||||
import { AppSettings, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, HistoryEntry, HistoryRetentionMode, PackageEntry, PackagePriority, SessionState } from "../shared/types";
|
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
|
||||||
import { defaultSettings } from "./constants";
|
import { defaultSettings } from "./constants";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
@ -16,50 +13,17 @@ const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "pack
|
|||||||
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
|
const VALID_SPEED_MODES = new Set(["global", "per_download"]);
|
||||||
const VALID_THEMES = new Set(["dark", "light"]);
|
const VALID_THEMES = new Set(["dark", "light"]);
|
||||||
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
|
const VALID_EXTRACT_CPU_PRIORITIES = new Set(["high", "middle", "low"]);
|
||||||
const VALID_HISTORY_RETENTION_MODES = new Set<HistoryRetentionMode>(["never", "session", "permanent"]);
|
|
||||||
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
|
const VALID_PACKAGE_PRIORITIES = new Set<string>(["high", "normal", "low"]);
|
||||||
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
const VALID_DOWNLOAD_STATUSES = new Set<DownloadStatus>([
|
||||||
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
"queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled"
|
||||||
]);
|
]);
|
||||||
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
const VALID_ITEM_PROVIDERS = new Set<DebridProvider>(["realdebrid", "megadebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink"]);
|
||||||
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]);
|
||||||
const SAFE_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
||||||
|
|
||||||
function asText(value: unknown): string {
|
function asText(value: unknown): string {
|
||||||
return String(value ?? "").trim();
|
return String(value ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSessionId(value: unknown): string {
|
|
||||||
const text = asText(value);
|
|
||||||
if (!text || !SAFE_SESSION_ID_RE.test(text)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPathInsideDir(filePath: string, dirPath: string): boolean {
|
|
||||||
try {
|
|
||||||
const resolvedFile = path.resolve(filePath);
|
|
||||||
const resolvedDir = path.resolve(dirPath);
|
|
||||||
const normalizedFile = process.platform === "win32" ? resolvedFile.toLowerCase() : resolvedFile;
|
|
||||||
const normalizedDir = process.platform === "win32" ? resolvedDir.toLowerCase() : resolvedDir;
|
|
||||||
return normalizedFile === normalizedDir || normalizedFile.startsWith(`${normalizedDir}${path.sep}`);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSessionTargetPath(value: unknown, packageOutputDir: string): string {
|
|
||||||
const targetPath = asText(value);
|
|
||||||
if (!targetPath || !packageOutputDir || !path.isAbsolute(targetPath)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (!isPathInsideDir(targetPath, packageOutputDir)) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return path.resolve(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!Number.isFinite(num)) {
|
if (!Number.isFinite(num)) {
|
||||||
@ -120,6 +84,7 @@ function normalizeColumnOrder(raw: unknown): string[] {
|
|||||||
result.push(col);
|
result.push(col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "name" is mandatory — ensure it's always present
|
||||||
if (!seen.has("name")) {
|
if (!seen.has("name")) {
|
||||||
result.unshift("name");
|
result.unshift("name");
|
||||||
}
|
}
|
||||||
@ -178,109 +143,6 @@ function normalizeDisabledProviders(raw: unknown): DebridProvider[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProviderByteMap(
|
|
||||||
raw: unknown,
|
|
||||||
megaDebridPreferApi: boolean,
|
|
||||||
megaDebridApiEnabled: boolean,
|
|
||||||
megaDebridWebEnabled: boolean,
|
|
||||||
mergeMode: "max" | "sum"
|
|
||||||
): Partial<Record<DebridProvider, number>> {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: Partial<Record<DebridProvider, number>> = {};
|
|
||||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
||||||
const provider = normalizeConfiguredProvider(key, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled);
|
|
||||||
if (!provider) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
||||||
if (bytes <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (mergeMode === "sum") {
|
|
||||||
result[provider] = (result[provider] || 0) + bytes;
|
|
||||||
} else {
|
|
||||||
result[provider] = Math.max(result[provider] || 0, bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeNamedByteMap(raw: unknown, allowedKeys: readonly string[]): Record<string, number> {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(allowedKeys);
|
|
||||||
const result: Record<string, number> = {};
|
|
||||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
|
||||||
const normalizedKey = String(key || "").trim();
|
|
||||||
if (!normalizedKey || !allowed.has(normalizedKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const bytes = clampNumber(value, 0, 0, Number.MAX_SAFE_INTEGER);
|
|
||||||
if (bytes <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[normalizedKey] = bytes;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDebridAccountStatuses(
|
|
||||||
value: unknown,
|
|
||||||
megaIds: string[],
|
|
||||||
debridLinkIds: string[]
|
|
||||||
): Record<string, DebridAccountStatus> {
|
|
||||||
const allowed = new Set([...megaIds, ...debridLinkIds]);
|
|
||||||
const result: Record<string, DebridAccountStatus> = {};
|
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
||||||
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
||||||
if (!allowed.has(key) || !raw || typeof raw !== "object") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const entry = raw as Partial<DebridAccountStatus>;
|
|
||||||
if (typeof entry.accountId !== "string" || typeof entry.checkedAt !== "number") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
result[key] = {
|
|
||||||
accountId: entry.accountId,
|
|
||||||
provider: entry.provider === "debridlink" ? "debridlink" : "megadebrid",
|
|
||||||
label: String(entry.label || ""),
|
|
||||||
maskedLogin: String(entry.maskedLogin || ""),
|
|
||||||
valid: Boolean(entry.valid),
|
|
||||||
isPremium: Boolean(entry.isPremium),
|
|
||||||
premiumUntilMs: typeof entry.premiumUntilMs === "number" ? entry.premiumUntilMs : null,
|
|
||||||
email: typeof entry.email === "string" ? entry.email : undefined,
|
|
||||||
message: String(entry.message || ""),
|
|
||||||
checkedAt: entry.checkedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeStringList(raw: unknown, allowedKeys: readonly string[]): string[] {
|
|
||||||
if (!Array.isArray(raw)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(allowedKeys);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: string[] = [];
|
|
||||||
for (const entry of raw) {
|
|
||||||
const normalized = String(entry || "").trim();
|
|
||||||
if (!normalized || !allowed.has(normalized) || seen.has(normalized)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(normalized);
|
|
||||||
result.push(normalized);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
|
function normalizeHosterRouting(raw: unknown, megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): Record<string, DebridProvider> {
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||||
const result: Record<string, DebridProvider> = {};
|
const result: Record<string, DebridProvider> = {};
|
||||||
@ -308,6 +170,7 @@ function normalizeProviderOrder(
|
|||||||
if (Array.isArray(raw) && raw.length > 0) {
|
if (Array.isArray(raw) && raw.length > 0) {
|
||||||
list = raw;
|
list = raw;
|
||||||
} else {
|
} else {
|
||||||
|
// Migrate from old primary/secondary/tertiary
|
||||||
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
|
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
|
||||||
(v) => v && String(v).trim() && String(v).trim() !== "none"
|
(v) => v && String(v).trim() && String(v).trim() !== "none"
|
||||||
);
|
);
|
||||||
@ -342,14 +205,8 @@ function migrateUpdateRepo(raw: string, fallback: string): string {
|
|||||||
|
|
||||||
export function normalizeSettings(settings: AppSettings): AppSettings {
|
export function normalizeSettings(settings: AppSettings): AppSettings {
|
||||||
const defaults = defaultSettings();
|
const defaults = defaultSettings();
|
||||||
const currentUsageDay = getProviderUsageDayKey();
|
|
||||||
const megaLogin = asText(settings.megaLogin);
|
const megaLogin = asText(settings.megaLogin);
|
||||||
const megaPassword = asText(settings.megaPassword);
|
const megaPassword = asText(settings.megaPassword);
|
||||||
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
|
|
||||||
if (!megaCredentials && megaLogin && megaPassword) {
|
|
||||||
megaCredentials = `${megaLogin}:${megaPassword}`;
|
|
||||||
}
|
|
||||||
const megaDebridAccountIds = getMegaDebridAccountIds(megaCredentials);
|
|
||||||
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
const megaDebridPreferApi = settings.megaDebridPreferApi !== undefined ? Boolean(settings.megaDebridPreferApi) : true;
|
||||||
const hasMegaCreds = Boolean(megaLogin && megaPassword);
|
const hasMegaCreds = Boolean(megaLogin && megaPassword);
|
||||||
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
|
const megaDebridApiEnabled = settings.megaDebridApiEnabled !== undefined
|
||||||
@ -358,40 +215,11 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
const megaDebridWebEnabled = settings.megaDebridWebEnabled !== undefined
|
||||||
? Boolean(settings.megaDebridWebEnabled)
|
? Boolean(settings.megaDebridWebEnabled)
|
||||||
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
: (hasMegaCreds ? !megaDebridPreferApi : defaults.megaDebridWebEnabled);
|
||||||
const providerDailyUsageDayRaw = asText(settings.providerDailyUsageDay);
|
|
||||||
const providerDailyUsageDay = /^\d{4}-\d{2}-\d{2}$/.test(providerDailyUsageDayRaw)
|
|
||||||
? providerDailyUsageDayRaw
|
|
||||||
: currentUsageDay;
|
|
||||||
const debridLinkApiKeyIds = getDebridLinkApiKeyIds(String(settings.debridLinkApiKeys ?? ""));
|
|
||||||
const providerDailyUsageBytes = normalizeProviderByteMap(
|
|
||||||
settings.providerDailyUsageBytes,
|
|
||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
||||||
"sum"
|
|
||||||
);
|
|
||||||
const providerTotalUsageBytes = normalizeProviderByteMap(
|
|
||||||
settings.providerTotalUsageBytes,
|
|
||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
||||||
"sum"
|
|
||||||
);
|
|
||||||
const debridLinkApiKeyDailyLimitBytes = normalizeNamedByteMap(
|
|
||||||
settings.debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyIds
|
|
||||||
);
|
|
||||||
const debridLinkApiKeyDailyUsageBytes = normalizeNamedByteMap(
|
|
||||||
settings.debridLinkApiKeyDailyUsageBytes,
|
|
||||||
debridLinkApiKeyIds
|
|
||||||
);
|
|
||||||
const debridLinkApiKeyTotalUsageBytes = normalizeNamedByteMap(
|
|
||||||
settings.debridLinkApiKeyTotalUsageBytes,
|
|
||||||
debridLinkApiKeyIds
|
|
||||||
);
|
|
||||||
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
|
|
||||||
const normalized: AppSettings = {
|
const normalized: AppSettings = {
|
||||||
token: asText(settings.token),
|
token: asText(settings.token),
|
||||||
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
|
||||||
megaLogin,
|
megaLogin,
|
||||||
megaPassword,
|
megaPassword,
|
||||||
megaCredentials,
|
|
||||||
megaDebridApiEnabled,
|
megaDebridApiEnabled,
|
||||||
megaDebridWebEnabled,
|
megaDebridWebEnabled,
|
||||||
megaDebridPreferApi,
|
megaDebridPreferApi,
|
||||||
@ -403,7 +231,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
ddownloadPassword: asText(settings.ddownloadPassword),
|
ddownloadPassword: asText(settings.ddownloadPassword),
|
||||||
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
oneFichierApiKey: asText(settings.oneFichierApiKey),
|
||||||
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
|
debridLinkApiKeys: String(settings.debridLinkApiKeys ?? "").replace(/\r\n|\r/g, "\n").trim(),
|
||||||
debridLinkDisabledKeyIds,
|
|
||||||
linkSnappyLogin: asText(settings.linkSnappyLogin),
|
linkSnappyLogin: asText(settings.linkSnappyLogin),
|
||||||
linkSnappyPassword: asText(settings.linkSnappyPassword),
|
linkSnappyPassword: asText(settings.linkSnappyPassword),
|
||||||
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
|
archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n|\r/g, "\n"),
|
||||||
@ -421,8 +248,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
packageName: asText(settings.packageName),
|
packageName: asText(settings.packageName),
|
||||||
autoExtract: Boolean(settings.autoExtract),
|
autoExtract: Boolean(settings.autoExtract),
|
||||||
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
|
||||||
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
|
|
||||||
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
|
|
||||||
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
|
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
|
||||||
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
|
||||||
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
|
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
|
||||||
@ -448,46 +273,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
|
|||||||
clipboardWatch: Boolean(settings.clipboardWatch),
|
clipboardWatch: Boolean(settings.clipboardWatch),
|
||||||
minimizeToTray: Boolean(settings.minimizeToTray),
|
minimizeToTray: Boolean(settings.minimizeToTray),
|
||||||
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
|
collapseNewPackages: settings.collapseNewPackages !== undefined ? Boolean(settings.collapseNewPackages) : defaults.collapseNewPackages,
|
||||||
historyRetentionMode: VALID_HISTORY_RETENTION_MODES.has(settings.historyRetentionMode)
|
|
||||||
? settings.historyRetentionMode
|
|
||||||
: defaults.historyRetentionMode,
|
|
||||||
accountListShowDetailedDebridLinkKeys: settings.accountListShowDetailedDebridLinkKeys !== undefined
|
|
||||||
? Boolean(settings.accountListShowDetailedDebridLinkKeys)
|
|
||||||
: defaults.accountListShowDetailedDebridLinkKeys,
|
|
||||||
autoSortPackagesByProgress: settings.autoSortPackagesByProgress !== undefined ? Boolean(settings.autoSortPackagesByProgress) : defaults.autoSortPackagesByProgress,
|
|
||||||
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
|
||||||
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
|
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
|
||||||
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
|
|
||||||
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
|
||||||
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
|
|
||||||
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
|
|
||||||
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
|
||||||
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
|
||||||
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
columnOrder: normalizeColumnOrder(settings.columnOrder),
|
||||||
extractCpuPriority: settings.extractCpuPriority,
|
extractCpuPriority: settings.extractCpuPriority,
|
||||||
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
|
||||||
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
|
||||||
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled),
|
hosterRouting: normalizeHosterRouting(settings.hosterRouting, megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled)
|
||||||
providerDailyLimitBytes: normalizeProviderByteMap(
|
|
||||||
settings.providerDailyLimitBytes,
|
|
||||||
megaDebridPreferApi, megaDebridApiEnabled, megaDebridWebEnabled,
|
|
||||||
"max"
|
|
||||||
),
|
|
||||||
providerDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? providerDailyUsageBytes : {},
|
|
||||||
providerTotalUsageBytes,
|
|
||||||
debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: providerDailyUsageDay === currentUsageDay ? debridLinkApiKeyDailyUsageBytes : {},
|
|
||||||
debridLinkApiKeyTotalUsageBytes,
|
|
||||||
megaDebridDisabledAccountIds: normalizeStringList(settings.megaDebridDisabledAccountIds, megaDebridAccountIds),
|
|
||||||
megaDebridAccountDailyLimitBytes: normalizeNamedByteMap(settings.megaDebridAccountDailyLimitBytes, megaDebridAccountIds),
|
|
||||||
megaDebridAccountDailyUsageBytes: providerDailyUsageDay === currentUsageDay
|
|
||||||
? normalizeNamedByteMap(settings.megaDebridAccountDailyUsageBytes, megaDebridAccountIds)
|
|
||||||
: {},
|
|
||||||
megaDebridAccountTotalUsageBytes: normalizeNamedByteMap(settings.megaDebridAccountTotalUsageBytes, megaDebridAccountIds),
|
|
||||||
debridAccountStatuses: normalizeDebridAccountStatuses(settings.debridAccountStatuses, megaDebridAccountIds, debridLinkApiKeyIds),
|
|
||||||
providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay,
|
|
||||||
scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) {
|
||||||
@ -534,7 +329,6 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings {
|
|||||||
realDebridUseWebLogin: settings.realDebridUseWebLogin,
|
realDebridUseWebLogin: settings.realDebridUseWebLogin,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
megaCredentials: "",
|
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
|
bestDebridUseWebLogin: settings.bestDebridUseWebLogin,
|
||||||
allDebridToken: "",
|
allDebridToken: "",
|
||||||
@ -564,22 +358,7 @@ export function createStoragePaths(baseDir: string): StoragePaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureBaseDir(baseDir: string): void {
|
function ensureBaseDir(baseDir: string): void {
|
||||||
try {
|
fs.mkdirSync(baseDir, { recursive: true });
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
||||||
if (code === "EACCES" || code === "EPERM") {
|
|
||||||
logger.error(`AppData-Ordner kann nicht erstellt werden (${code}): ${baseDir} - pruefe Schreibrechte fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJsonReplacer(_key: string, value: unknown): unknown {
|
|
||||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
@ -597,14 +376,7 @@ function readSettingsFile(filePath: string): AppSettings | null {
|
|||||||
...parsed
|
...parsed
|
||||||
});
|
});
|
||||||
return sanitizeCredentialPersistence(merged);
|
return sanitizeCredentialPersistence(merged);
|
||||||
} catch (error) {
|
} catch {
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
||||||
if (code === "ENOENT") {
|
|
||||||
} else if (code === "EACCES" || code === "EPERM") {
|
|
||||||
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Settings-Datei nicht lesbar: ${filePath}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -624,8 +396,8 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = normalizeSessionId(item.id) || normalizeSessionId(entryId);
|
const id = asText(item.id) || entryId;
|
||||||
const packageId = normalizeSessionId(item.packageId);
|
const packageId = asText(item.packageId);
|
||||||
const url = asText(item.url);
|
const url = asText(item.url);
|
||||||
if (!id || !packageId || !url) {
|
if (!id || !packageId || !url) {
|
||||||
continue;
|
continue;
|
||||||
@ -642,9 +414,6 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
packageId,
|
packageId,
|
||||||
url,
|
url,
|
||||||
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
provider: VALID_ITEM_PROVIDERS.has(providerRaw) ? providerRaw : null,
|
||||||
providerLabel: asText(item.providerLabel) || undefined,
|
|
||||||
providerAccountId: asText(item.providerAccountId) || undefined,
|
|
||||||
providerAccountLabel: asText(item.providerAccountLabel) || undefined,
|
|
||||||
status,
|
status,
|
||||||
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
retries: clampNumber(item.retries, 0, 0, 1_000_000),
|
||||||
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
speedBps: clampNumber(item.speedBps, 0, 0, 10_000_000_000),
|
||||||
@ -670,7 +439,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
if (!pkg) {
|
if (!pkg) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const id = normalizeSessionId(pkg.id) || normalizeSessionId(entryId);
|
const id = asText(pkg.id) || entryId;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -684,44 +453,21 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
extractDir: asText(pkg.extractDir),
|
extractDir: asText(pkg.extractDir),
|
||||||
status,
|
status,
|
||||||
itemIds: rawItemIds
|
itemIds: rawItemIds
|
||||||
.map((value) => normalizeSessionId(value))
|
.map((value) => asText(value))
|
||||||
.filter((value) => value.length > 0),
|
.filter((value) => value.length > 0),
|
||||||
cancelled: Boolean(pkg.cancelled),
|
cancelled: Boolean(pkg.cancelled),
|
||||||
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled),
|
||||||
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
|
priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal",
|
||||||
downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
||||||
downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER),
|
|
||||||
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER),
|
||||||
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let orphanedItemCount = 0;
|
|
||||||
for (const [itemId, item] of Object.entries(itemsById)) {
|
for (const [itemId, item] of Object.entries(itemsById)) {
|
||||||
if (!packagesById[item.packageId]) {
|
if (!packagesById[item.packageId]) {
|
||||||
orphanedItemCount += 1;
|
|
||||||
delete itemsById[itemId];
|
delete itemsById[itemId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (orphanedItemCount > 0) {
|
|
||||||
logger.warn(`normalizeLoadedSession: ${orphanedItemCount} verwaiste Items entfernt (fehlende Pakete)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let droppedUnsafeTargetPathCount = 0;
|
|
||||||
for (const item of Object.values(itemsById)) {
|
|
||||||
const pkg = packagesById[item.packageId];
|
|
||||||
if (!pkg) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const safeTargetPath = normalizeSessionTargetPath(item.targetPath, pkg.outputDir);
|
|
||||||
if (!safeTargetPath && asText(item.targetPath)) {
|
|
||||||
droppedUnsafeTargetPathCount += 1;
|
|
||||||
}
|
|
||||||
item.targetPath = safeTargetPath;
|
|
||||||
}
|
|
||||||
if (droppedUnsafeTargetPathCount > 0) {
|
|
||||||
logger.warn(`normalizeLoadedSession: ${droppedUnsafeTargetPathCount} unsichere targetPath-Eintraege verworfen`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pkg of Object.values(packagesById)) {
|
for (const pkg of Object.values(packagesById)) {
|
||||||
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
||||||
@ -733,7 +479,7 @@ export function normalizeLoadedSession(raw: unknown): SessionState {
|
|||||||
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
|
const rawOrder = Array.isArray(parsed.packageOrder) ? parsed.packageOrder : [];
|
||||||
const seenOrder = new Set<string>();
|
const seenOrder = new Set<string>();
|
||||||
const packageOrder = rawOrder
|
const packageOrder = rawOrder
|
||||||
.map((entry) => normalizeSessionId(entry))
|
.map((entry) => asText(entry))
|
||||||
.filter((id) => {
|
.filter((id) => {
|
||||||
if (!(id in packagesById) || seenOrder.has(id)) {
|
if (!(id in packagesById) || seenOrder.has(id)) {
|
||||||
return false;
|
return false;
|
||||||
@ -780,11 +526,12 @@ export function loadSettings(paths: StoragePaths): AppSettings {
|
|||||||
if (backupLoaded) {
|
if (backupLoaded) {
|
||||||
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
|
logger.warn("Konfiguration defekt, Backup-Datei wird verwendet");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify(backupLoaded, safeJsonReplacer, 2);
|
const payload = JSON.stringify(backupLoaded, null, 2);
|
||||||
const tempPath = `${paths.configFile}.tmp`;
|
const tempPath = `${paths.configFile}.tmp`;
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore restore write failure
|
||||||
}
|
}
|
||||||
return backupLoaded;
|
return backupLoaded;
|
||||||
}
|
}
|
||||||
@ -815,15 +562,18 @@ function sessionBackupPath(sessionFile: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
|
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
|
||||||
|
// Reset transient fields that may be stale from a previous crash
|
||||||
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
||||||
for (const item of Object.values(session.items)) {
|
for (const item of Object.values(session.items)) {
|
||||||
if (ACTIVE_STATUSES.has(item.status)) {
|
if (ACTIVE_STATUSES.has(item.status)) {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.lastError = "";
|
item.lastError = "";
|
||||||
}
|
}
|
||||||
|
// Always clear stale speed values
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset package-level active statuses to queued (mirrors item reset above)
|
||||||
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
||||||
for (const pkg of Object.values(session.packages)) {
|
for (const pkg of Object.values(session.packages)) {
|
||||||
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
|
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
|
||||||
@ -832,6 +582,7 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
|||||||
pkg.postProcessLabel = undefined;
|
pkg.postProcessLabel = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear stale session-level running/paused flags
|
||||||
session.running = false;
|
session.running = false;
|
||||||
session.paused = false;
|
session.paused = false;
|
||||||
|
|
||||||
@ -841,38 +592,30 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
|||||||
function readSessionFile(filePath: string): SessionState | null {
|
function readSessionFile(filePath: string): SessionState | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
||||||
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
return normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
|
||||||
const pkgCount = Object.keys(session.packages).length;
|
} catch {
|
||||||
const itemCount = Object.keys(session.items).length;
|
|
||||||
logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`);
|
|
||||||
return session;
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException)?.code || "";
|
|
||||||
if (code === "EACCES" || code === "EPERM") {
|
|
||||||
logger.error(`Session-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
|
|
||||||
} else {
|
|
||||||
logger.error(`Session-Datei nicht lesbar: ${filePath}: ${String(error)}`);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
|
// Create a backup of the existing config before overwriting
|
||||||
if (fs.existsSync(paths.configFile)) {
|
if (fs.existsSync(paths.configFile)) {
|
||||||
try {
|
try {
|
||||||
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
|
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
|
||||||
} catch {
|
} catch {
|
||||||
|
// Best-effort backup; proceed even if it fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||||
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
const payload = JSON.stringify(persisted, null, 2);
|
||||||
const tempPath = `${paths.configFile}.tmp`;
|
const tempPath = `${paths.configFile}.tmp`;
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
syncRenameWithExdevFallback(tempPath, paths.configFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -900,7 +643,7 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
|
|||||||
|
|
||||||
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
export async function saveSettingsAsync(paths: StoragePaths, settings: AppSettings): Promise<void> {
|
||||||
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
|
||||||
const payload = JSON.stringify(persisted, safeJsonReplacer, 2);
|
const payload = JSON.stringify(persisted, null, 2);
|
||||||
if (asyncSettingsSaveRunning) {
|
if (asyncSettingsSaveRunning) {
|
||||||
asyncSettingsSaveQueued = { paths, settings };
|
asyncSettingsSaveQueued = { paths, settings };
|
||||||
return;
|
return;
|
||||||
@ -939,73 +682,31 @@ export function emptySession(): SessionState {
|
|||||||
|
|
||||||
export function loadSession(paths: StoragePaths): SessionState {
|
export function loadSession(paths: StoragePaths): SessionState {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
const backupFile = sessionBackupPath(paths.sessionFile);
|
if (!fs.existsSync(paths.sessionFile)) {
|
||||||
const primaryExists = fs.existsSync(paths.sessionFile);
|
return emptySession();
|
||||||
if (!primaryExists) {
|
|
||||||
const hasRecoverable = fs.existsSync(backupFile)
|
|
||||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
|
|
||||||
|| fs.existsSync(sessionTempPath(paths.sessionFile, "async"));
|
|
||||||
if (!hasRecoverable) {
|
|
||||||
logger.info("Keine Session-Datei vorhanden, starte mit leerer Session");
|
|
||||||
return emptySession();
|
|
||||||
}
|
|
||||||
logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
|
const primary = readSessionFile(paths.sessionFile);
|
||||||
|
|
||||||
if (primary) {
|
if (primary) {
|
||||||
const primaryPkgCount = Object.keys(primary.packages).length;
|
|
||||||
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
|
|
||||||
const backup = readSessionFile(backupFile);
|
|
||||||
if (backup) {
|
|
||||||
const backupPkgCount = Object.keys(backup.packages).length;
|
|
||||||
if (backupPkgCount > 0) {
|
|
||||||
logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`);
|
|
||||||
try {
|
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return backup;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return primary;
|
return primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const backupFile = sessionBackupPath(paths.sessionFile);
|
||||||
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
|
const backup = fs.existsSync(backupFile) ? readSessionFile(backupFile) : null;
|
||||||
if (backup) {
|
if (backup) {
|
||||||
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
logger.warn("Session defekt, Backup-Datei wird verwendet");
|
||||||
try {
|
try {
|
||||||
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...backup, updatedAt: Date.now() });
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore restore write failure
|
||||||
}
|
}
|
||||||
return backup;
|
return backup;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const kind of ["sync", "async"] as const) {
|
logger.error("Session konnte nicht geladen werden (auch Backup fehlgeschlagen)");
|
||||||
const tmpPath = sessionTempPath(paths.sessionFile, kind);
|
|
||||||
if (fs.existsSync(tmpPath)) {
|
|
||||||
const tmpSession = readSessionFile(tmpPath);
|
|
||||||
if (tmpSession && Object.keys(tmpSession.packages).length > 0) {
|
|
||||||
logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`);
|
|
||||||
try {
|
|
||||||
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
|
|
||||||
fs.writeFileSync(paths.sessionFile, payload, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
return tmpSession;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen)");
|
|
||||||
return emptySession();
|
return emptySession();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1016,21 +717,22 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
|
|||||||
try {
|
try {
|
||||||
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
|
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
|
||||||
} catch {
|
} catch {
|
||||||
|
// Best-effort backup; proceed even if it fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
const tempPath = sessionTempPath(paths.sessionFile, "sync");
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let asyncSaveRunning = false;
|
let asyncSaveRunning = false;
|
||||||
let asyncSaveQueued: { paths: StoragePaths; payload: string; generation: number } | null = null;
|
let asyncSaveQueued: { paths: StoragePaths; payload: string } | null = null;
|
||||||
let syncSaveGeneration = 0;
|
let syncSaveGeneration = 0;
|
||||||
|
|
||||||
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
async function writeSessionPayload(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
||||||
@ -1038,6 +740,7 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
|
|||||||
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
|
||||||
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
const tempPath = sessionTempPath(paths.sessionFile, "async");
|
||||||
await fsp.writeFile(tempPath, payload, "utf8");
|
await fsp.writeFile(tempPath, payload, "utf8");
|
||||||
|
// If a synchronous save occurred after this async save started, discard the stale write
|
||||||
if (generation < syncSaveGeneration) {
|
if (generation < syncSaveGeneration) {
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
return;
|
return;
|
||||||
@ -1059,14 +762,15 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
|
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string): Promise<void> {
|
||||||
if (asyncSaveRunning) {
|
if (asyncSaveRunning) {
|
||||||
asyncSaveQueued = { paths, payload, generation };
|
asyncSaveQueued = { paths, payload };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
asyncSaveRunning = true;
|
asyncSaveRunning = true;
|
||||||
|
const gen = syncSaveGeneration;
|
||||||
try {
|
try {
|
||||||
await writeSessionPayload(paths, payload, generation);
|
await writeSessionPayload(paths, payload, gen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
logger.error(`Async Session-Save fehlgeschlagen: ${String(error)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@ -1074,7 +778,7 @@ async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, gen
|
|||||||
if (asyncSaveQueued) {
|
if (asyncSaveQueued) {
|
||||||
const queued = asyncSaveQueued;
|
const queued = asyncSaveQueued;
|
||||||
asyncSaveQueued = null;
|
asyncSaveQueued = null;
|
||||||
void saveSessionPayloadAsync(queued.paths, queued.payload, queued.generation);
|
void saveSessionPayloadAsync(queued.paths, queued.payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1086,14 +790,13 @@ export function cancelPendingAsyncSaves(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
|
||||||
const generation = syncSaveGeneration;
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
|
await saveSessionPayloadAsync(paths, payload);
|
||||||
await saveSessionPayloadAsync(paths, payload, generation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_HISTORY_ENTRIES = 500;
|
const MAX_HISTORY_ENTRIES = 500;
|
||||||
|
|
||||||
export function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
|
function normalizeHistoryEntry(raw: unknown, index: number): HistoryEntry | null {
|
||||||
const entry = asRecord(raw);
|
const entry = asRecord(raw);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
@ -1140,13 +843,13 @@ export function loadHistory(paths: StoragePaths): HistoryEntry[] {
|
|||||||
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void {
|
export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES);
|
const trimmed = entries.slice(0, MAX_HISTORY_ENTRIES);
|
||||||
const payload = JSON.stringify(trimmed, safeJsonReplacer, 2);
|
const payload = JSON.stringify(trimmed, null, 2);
|
||||||
const tempPath = `${paths.historyFile}.tmp`;
|
const tempPath = `${paths.historyFile}.tmp`;
|
||||||
try {
|
try {
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
syncRenameWithExdevFallback(tempPath, paths.historyFile);
|
syncRenameWithExdevFallback(tempPath, paths.historyFile);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { fs.rmSync(tempPath, { force: true }); } catch { }
|
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1158,24 +861,6 @@ export function addHistoryEntry(paths: StoragePaths, entry: HistoryEntry): Histo
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): HistoryEntry[] {
|
|
||||||
return retentionMode === "never" ? [] : loadHistory(paths);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addHistoryEntryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode, entry: HistoryEntry): HistoryEntry[] {
|
|
||||||
if (retentionMode === "never") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return addHistoryEntry(paths, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetHistoryForRetention(paths: StoragePaths, retentionMode: HistoryRetentionMode): void {
|
|
||||||
if (retentionMode === "permanent") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearHistory(paths);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
|
export function removeHistoryEntry(paths: StoragePaths, entryId: string): HistoryEntry[] {
|
||||||
const existing = loadHistory(paths);
|
const existing = loadHistory(paths);
|
||||||
const updated = existing.filter(e => e.id !== entryId);
|
const updated = existing.filter(e => e.id !== entryId);
|
||||||
@ -1189,6 +874,7 @@ export function clearHistory(paths: StoragePaths): void {
|
|||||||
try {
|
try {
|
||||||
fs.unlinkSync(paths.historyFile);
|
fs.unlinkSync(paths.historyFile);
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,210 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import { APP_VERSION } from "./constants";
|
|
||||||
import { getAuditLogPath } from "./audit-log";
|
|
||||||
import { getDebugSetupCheck } from "./debug-setup";
|
|
||||||
import { getLogFilePath } from "./logger";
|
|
||||||
import { getRecentErrors } from "./error-ring";
|
|
||||||
import { getPackageLogPath } from "./package-log";
|
|
||||||
import { getRenameLogPath } from "./rename-log";
|
|
||||||
import { getDesktopRenameLogPath } from "./desktop-rename-log";
|
|
||||||
import { getSessionLogPath } from "./session-log";
|
|
||||||
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
|
|
||||||
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
|
|
||||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
|
|
||||||
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
|
||||||
import type { DownloadManager } from "./download-manager";
|
|
||||||
|
|
||||||
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
|
|
||||||
|
|
||||||
function safeReadJson(filePath: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addJson(zip: AdmZip, zipPath: string, value: unknown): void {
|
|
||||||
zip.addFile(zipPath, Buffer.from(`${JSON.stringify(value, null, 2)}\n`, "utf8"));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFileIfExists(zip: AdmZip, sourcePath: string | null, zipPath: string): void {
|
|
||||||
if (!sourcePath || !fs.existsSync(sourcePath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
zip.addLocalFile(sourcePath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): void {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
|
||||||
const zipPath = path.posix.join(zipRoot, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
addDirectoryIfExists(zip, fullPath, zipPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
zip.addLocalFile(fullPath, path.posix.dirname(zipPath), path.posix.basename(zipPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
|
|
||||||
if (!fs.existsSync(dirPath)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const cutoff = Date.now() - maxAgeMs;
|
|
||||||
let added = 0;
|
|
||||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isFile()) continue;
|
|
||||||
const fullPath = path.join(dirPath, entry.name);
|
|
||||||
try {
|
|
||||||
if (fs.statSync(fullPath).mtimeMs >= cutoff) {
|
|
||||||
zip.addLocalFile(fullPath, zipRoot, entry.name);
|
|
||||||
added += 1;
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestampForFileName(date: Date): string {
|
|
||||||
const y = date.getFullYear();
|
|
||||||
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(date.getDate()).padStart(2, "0");
|
|
||||||
const h = String(date.getHours()).padStart(2, "0");
|
|
||||||
const mi = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
const s = String(date.getSeconds()).padStart(2, "0");
|
|
||||||
return `${y}-${mo}-${d}_${h}-${mi}-${s}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSupportBundleDefaultFileName(): string {
|
|
||||||
return `rd-support-bundle-${formatTimestampForFileName(new Date())}.zip`;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HostDiagnosticsMode = "full" | "cached" | "none";
|
|
||||||
|
|
||||||
interface BuildSupportBundleOptions {
|
|
||||||
hostDiagnosticsMode?: HostDiagnosticsMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDeferredHostDiagnostics(reason: string): unknown {
|
|
||||||
return {
|
|
||||||
collectedAt: new Date().toISOString(),
|
|
||||||
supported: process.platform === "win32",
|
|
||||||
platform: process.platform,
|
|
||||||
crashControl: null,
|
|
||||||
recentKernelPower: [],
|
|
||||||
recentWerKernel: [],
|
|
||||||
recentKernelDump: [],
|
|
||||||
recentAppCrashes: [],
|
|
||||||
recentMinidumps: [],
|
|
||||||
assessmentHints: [
|
|
||||||
reason
|
|
||||||
],
|
|
||||||
errors: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
|
|
||||||
if (mode === "none") {
|
|
||||||
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
|
|
||||||
}
|
|
||||||
if (mode === "cached") {
|
|
||||||
const cached = getCachedWindowsHostDiagnostics();
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
|
|
||||||
}
|
|
||||||
return getWindowsHostDiagnostics();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Buffer {
|
|
||||||
const zip = new AdmZip();
|
|
||||||
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
|
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
|
||||||
const settings = loadSettings(storagePaths);
|
|
||||||
const history = loadHistory(storagePaths);
|
|
||||||
const snapshot = manager.getSnapshot();
|
|
||||||
const packageIds = Object.keys(snapshot.session.packages);
|
|
||||||
const itemIds = Object.keys(snapshot.session.items);
|
|
||||||
const debugSetup = getDebugSetupCheck(baseDir);
|
|
||||||
|
|
||||||
addJson(zip, "overview/meta.json", {
|
|
||||||
appVersion: APP_VERSION,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
runtimeBaseDir: baseDir,
|
|
||||||
packageCount: packageIds.length,
|
|
||||||
itemCount: itemIds.length
|
|
||||||
});
|
|
||||||
addJson(zip, "overview/status.json", snapshot.session);
|
|
||||||
addJson(zip, "overview/settings.json", buildRedactedSettingsPayload(settings));
|
|
||||||
addJson(zip, "overview/accounts.json", buildAccountSummary(settings));
|
|
||||||
addJson(zip, "overview/stats.json", {
|
|
||||||
...buildStatsPayload(snapshot),
|
|
||||||
allTime: {
|
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs
|
|
||||||
}
|
|
||||||
});
|
|
||||||
addJson(zip, "overview/debug-setup.json", debugSetup);
|
|
||||||
addJson(zip, "overview/self-check.json", debugSetup);
|
|
||||||
addJson(zip, "overview/history.json", {
|
|
||||||
total: history.length,
|
|
||||||
entries: history.map((entry) => summarizeHistoryEntry(entry))
|
|
||||||
});
|
|
||||||
addJson(zip, "overview/packages.json", {
|
|
||||||
count: packageIds.length,
|
|
||||||
packages: packageIds.map((packageId) => snapshot.session.packages[packageId]).filter(Boolean)
|
|
||||||
});
|
|
||||||
addJson(zip, "overview/items.json", {
|
|
||||||
count: itemIds.length,
|
|
||||||
items: itemIds.map((itemId) => snapshot.session.items[itemId]).filter(Boolean)
|
|
||||||
});
|
|
||||||
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
|
|
||||||
addJson(zip, "overview/trace-config.json", getTraceConfig());
|
|
||||||
const recentErrors = getRecentErrors();
|
|
||||||
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
|
|
||||||
|
|
||||||
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
|
|
||||||
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
|
|
||||||
addFileIfExists(zip, path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
|
|
||||||
addFileIfExists(zip, getTraceConfigPath(), "runtime/trace_config.json");
|
|
||||||
|
|
||||||
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
|
|
||||||
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
|
|
||||||
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
|
|
||||||
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
|
|
||||||
addFileIfExists(zip, getRenameLogPath(), "logs/rename.log");
|
|
||||||
addFileIfExists(zip, getRenameLogPath() ? `${getRenameLogPath()}.old` : null, "logs/rename.log.old");
|
|
||||||
addFileIfExists(zip, getDesktopRenameLogPath(), "logs/rename-session-desktop.txt");
|
|
||||||
addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
|
|
||||||
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
|
||||||
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
|
||||||
|
|
||||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
|
||||||
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
|
||||||
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
|
||||||
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
|
||||||
|
|
||||||
for (const packageId of packageIds) {
|
|
||||||
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
|
||||||
}
|
|
||||||
for (const itemId of itemIds) {
|
|
||||||
addFileIfExists(zip, manager.getItemLogPath(itemId), `logs/live/item-${itemId}.txt`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aiManifest = safeReadJson(path.join(baseDir, AI_MANIFEST_FILE));
|
|
||||||
if (aiManifest) {
|
|
||||||
addJson(zip, "overview/ai-manifest.json", aiManifest);
|
|
||||||
}
|
|
||||||
|
|
||||||
return zip.toBuffer();
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
|
||||||
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
|
|
||||||
|
|
||||||
function hasText(value: unknown): boolean {
|
|
||||||
return String(value || "").trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
|
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
|
|
||||||
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
realDebrid: {
|
|
||||||
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
|
|
||||||
tokenConfigured: hasText(settings.token),
|
|
||||||
webLoginEnabled: settings.realDebridUseWebLogin,
|
|
||||||
rememberToken: settings.rememberToken
|
|
||||||
},
|
|
||||||
megaDebrid: {
|
|
||||||
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|
|
||||||
|| settings.megaDebridApiEnabled
|
|
||||||
|| settings.megaDebridWebEnabled,
|
|
||||||
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
|
|
||||||
apiEnabled: settings.megaDebridApiEnabled,
|
|
||||||
webEnabled: settings.megaDebridWebEnabled,
|
|
||||||
preferApi: settings.megaDebridPreferApi
|
|
||||||
},
|
|
||||||
bestDebrid: {
|
|
||||||
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
|
|
||||||
tokenConfigured: hasText(settings.bestToken),
|
|
||||||
webLoginEnabled: settings.bestDebridUseWebLogin
|
|
||||||
},
|
|
||||||
allDebrid: {
|
|
||||||
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
|
|
||||||
tokenConfigured: hasText(settings.allDebridToken),
|
|
||||||
webLoginEnabled: settings.allDebridUseWebLogin
|
|
||||||
},
|
|
||||||
ddownload: {
|
|
||||||
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
|
|
||||||
},
|
|
||||||
oneFichier: {
|
|
||||||
configured: hasText(settings.oneFichierApiKey)
|
|
||||||
},
|
|
||||||
debridLink: {
|
|
||||||
configured: debridLinkKeyIds.length > 0,
|
|
||||||
keyCount: debridLinkKeyIds.length,
|
|
||||||
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
|
|
||||||
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
|
|
||||||
},
|
|
||||||
linkSnappy: {
|
|
||||||
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
|
|
||||||
},
|
|
||||||
disabledProviders: [...(settings.disabledProviders || [])]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
|
|
||||||
const before = buildAccountSummary(previous);
|
|
||||||
const after = buildAccountSummary(next);
|
|
||||||
const changes: Record<string, unknown> = {};
|
|
||||||
for (const key of Object.keys(after)) {
|
|
||||||
const beforeJson = JSON.stringify(before[key]);
|
|
||||||
const afterJson = JSON.stringify(after[key]);
|
|
||||||
if (beforeJson !== afterJson) {
|
|
||||||
changes[key] = after[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
paths: {
|
|
||||||
outputDir: settings.outputDir,
|
|
||||||
extractDir: settings.extractDir,
|
|
||||||
mkvLibraryDir: settings.mkvLibraryDir
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
providerOrder: settings.providerOrder,
|
|
||||||
providerPrimary: settings.providerPrimary,
|
|
||||||
providerSecondary: settings.providerSecondary,
|
|
||||||
providerTertiary: settings.providerTertiary,
|
|
||||||
autoProviderFallback: settings.autoProviderFallback,
|
|
||||||
disabledProviders: settings.disabledProviders,
|
|
||||||
hosterRouting: settings.hosterRouting
|
|
||||||
},
|
|
||||||
extraction: {
|
|
||||||
autoExtract: settings.autoExtract,
|
|
||||||
autoExtractWhenStopped: settings.autoExtractWhenStopped,
|
|
||||||
hybridExtract: settings.hybridExtract,
|
|
||||||
createExtractSubfolder: settings.createExtractSubfolder,
|
|
||||||
cleanupMode: settings.cleanupMode,
|
|
||||||
extractConflictMode: settings.extractConflictMode,
|
|
||||||
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
|
|
||||||
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
|
|
||||||
enableIntegrityCheck: settings.enableIntegrityCheck,
|
|
||||||
archivePasswordCount: String(settings.archivePasswordList || "")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.length,
|
|
||||||
extractCpuPriority: settings.extractCpuPriority,
|
|
||||||
maxParallelExtract: settings.maxParallelExtract
|
|
||||||
},
|
|
||||||
downloads: {
|
|
||||||
maxParallel: settings.maxParallel,
|
|
||||||
retryLimit: settings.retryLimit,
|
|
||||||
autoResumeOnStart: settings.autoResumeOnStart,
|
|
||||||
autoReconnect: settings.autoReconnect,
|
|
||||||
reconnectWaitSeconds: settings.reconnectWaitSeconds,
|
|
||||||
autoSkipExtracted: settings.autoSkipExtracted,
|
|
||||||
completedCleanupPolicy: settings.completedCleanupPolicy
|
|
||||||
},
|
|
||||||
ui: {
|
|
||||||
packageName: settings.packageName,
|
|
||||||
theme: settings.theme,
|
|
||||||
collapseNewPackages: settings.collapseNewPackages,
|
|
||||||
hideExtractedItems: settings.hideExtractedItems,
|
|
||||||
confirmDeleteSelection: settings.confirmDeleteSelection,
|
|
||||||
clipboardWatch: settings.clipboardWatch,
|
|
||||||
minimizeToTray: settings.minimizeToTray,
|
|
||||||
columnOrder: settings.columnOrder
|
|
||||||
},
|
|
||||||
bandwidth: {
|
|
||||||
speedLimitEnabled: settings.speedLimitEnabled,
|
|
||||||
speedLimitKbps: settings.speedLimitKbps,
|
|
||||||
speedLimitMode: settings.speedLimitMode,
|
|
||||||
bandwidthSchedules: settings.bandwidthSchedules
|
|
||||||
},
|
|
||||||
updates: {
|
|
||||||
updateRepo: settings.updateRepo,
|
|
||||||
autoUpdateCheck: settings.autoUpdateCheck
|
|
||||||
},
|
|
||||||
statistics: {
|
|
||||||
totalDownloadedAllTime: settings.totalDownloadedAllTime,
|
|
||||||
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
|
|
||||||
totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs,
|
|
||||||
providerDailyLimitBytes: settings.providerDailyLimitBytes,
|
|
||||||
providerDailyUsageBytes: settings.providerDailyUsageBytes,
|
|
||||||
providerTotalUsageBytes: settings.providerTotalUsageBytes,
|
|
||||||
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
|
|
||||||
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
|
|
||||||
providerDailyUsageDay: settings.providerDailyUsageDay
|
|
||||||
},
|
|
||||||
accounts: buildAccountSummary(settings)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
session: snapshot.stats,
|
|
||||||
totals: {
|
|
||||||
totalPackages: Object.keys(snapshot.session.packages).length,
|
|
||||||
totalItems: Object.keys(snapshot.session.items).length,
|
|
||||||
speedText: snapshot.speedText,
|
|
||||||
etaText: snapshot.etaText,
|
|
||||||
canStart: snapshot.canStart,
|
|
||||||
canStop: snapshot.canStop,
|
|
||||||
canPause: snapshot.canPause
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
id: entry.id,
|
|
||||||
name: entry.name,
|
|
||||||
status: entry.status,
|
|
||||||
provider: entry.provider,
|
|
||||||
fileCount: entry.fileCount,
|
|
||||||
totalBytes: entry.totalBytes,
|
|
||||||
downloadedBytes: entry.downloadedBytes,
|
|
||||||
durationSeconds: entry.durationSeconds,
|
|
||||||
completedAt: entry.completedAt,
|
|
||||||
outputDir: entry.outputDir,
|
|
||||||
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { logTimestamp } from "./log-timestamp";
|
|
||||||
import path from "node:path";
|
|
||||||
import { addLogListener, removeLogListener } from "./logger";
|
|
||||||
import type { SupportTraceConfig } from "../shared/types";
|
|
||||||
|
|
||||||
type TraceLevel = "INFO" | "WARN" | "ERROR";
|
|
||||||
|
|
||||||
const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
|
|
||||||
const TRACE_CONFIG_FILE = "trace_config.json";
|
|
||||||
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
|
|
||||||
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
|
|
||||||
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
|
|
||||||
enabled: false,
|
|
||||||
includeMainLog: true,
|
|
||||||
includeAudit: true,
|
|
||||||
logDebugRequests: true,
|
|
||||||
autoDisableAt: null,
|
|
||||||
updatedAt: new Date(0).toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
let traceLogPath: string | null = null;
|
|
||||||
let traceConfigPath: string | null = null;
|
|
||||||
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
let pendingLines: string[] = [];
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
|
||||||
let autoDisableTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
function sanitizeFieldValue(value: unknown): string {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value.replace(/\r?\n/g, "\\n");
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFields(fields?: Record<string, unknown>): string {
|
|
||||||
if (!fields) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const parts = Object.entries(fields)
|
|
||||||
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
|
|
||||||
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
|
|
||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPending(): void {
|
|
||||||
if (!traceLogPath || pendingLines.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunk = pendingLines.join("");
|
|
||||||
pendingLines = [];
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateIfNeeded(filePath: string): void {
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(filePath);
|
|
||||||
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
fs.renameSync(filePath, backup);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupOldBackup(filePath: string): void {
|
|
||||||
const backup = `${filePath}.old`;
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(backup);
|
|
||||||
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
|
|
||||||
if (stat.mtimeMs < cutoff) {
|
|
||||||
fs.rmSync(backup, { force: true });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleFlush(): void {
|
|
||||||
if (flushTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
flushTimer = setTimeout(() => {
|
|
||||||
flushTimer = null;
|
|
||||||
flushPending();
|
|
||||||
}, TRACE_LOG_FLUSH_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendTraceLine(line: string): void {
|
|
||||||
if (!traceLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rotateIfNeeded(traceLogPath);
|
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingLines.push(line);
|
|
||||||
scheduleFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
}
|
|
||||||
const value = raw as Partial<SupportTraceConfig>;
|
|
||||||
return {
|
|
||||||
enabled: Boolean(value.enabled),
|
|
||||||
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
|
|
||||||
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
|
|
||||||
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
|
|
||||||
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
|
|
||||||
? value.autoDisableAt
|
|
||||||
: null,
|
|
||||||
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
|
|
||||||
? value.updatedAt
|
|
||||||
: DEFAULT_TRACE_CONFIG.updatedAt
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadTraceConfig(): SupportTraceConfig {
|
|
||||||
if (!traceConfigPath) {
|
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(traceConfigPath, "utf8")) as unknown;
|
|
||||||
return normalizeTraceConfig(parsed);
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistTraceConfig(): void {
|
|
||||||
if (!traceConfigPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainLogListener = (line: string): void => {
|
|
||||||
if (!traceConfig.enabled || !traceConfig.includeMainLog) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendTraceLine(line);
|
|
||||||
};
|
|
||||||
|
|
||||||
function clearAutoDisableTimer(): void {
|
|
||||||
if (autoDisableTimer) {
|
|
||||||
clearTimeout(autoDisableTimer);
|
|
||||||
autoDisableTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableTraceDueToExpiry(): void {
|
|
||||||
clearAutoDisableTimer();
|
|
||||||
if (!traceConfig.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
traceConfig = normalizeTraceConfig({
|
|
||||||
...traceConfig,
|
|
||||||
enabled: false,
|
|
||||||
autoDisableAt: null,
|
|
||||||
updatedAt: logTimestamp()
|
|
||||||
});
|
|
||||||
persistTraceConfig();
|
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleAutoDisable(): void {
|
|
||||||
clearAutoDisableTimer();
|
|
||||||
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const until = Date.parse(traceConfig.autoDisableAt);
|
|
||||||
if (!Number.isFinite(until)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const remainingMs = until - Date.now();
|
|
||||||
if (remainingMs <= 0) {
|
|
||||||
disableTraceDueToExpiry();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
autoDisableTimer = setTimeout(() => {
|
|
||||||
autoDisableTimer = null;
|
|
||||||
disableTraceDueToExpiry();
|
|
||||||
}, Math.min(remainingMs, 2_147_483_647));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initTraceLog(baseDir: string): void {
|
|
||||||
traceLogPath = path.join(baseDir, "trace.log");
|
|
||||||
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
|
||||||
cleanupOldBackup(traceLogPath);
|
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
rotateIfNeeded(traceLogPath);
|
|
||||||
if (!fs.existsSync(traceLogPath)) {
|
|
||||||
fs.writeFileSync(traceLogPath, "", "utf8");
|
|
||||||
}
|
|
||||||
traceConfig = loadTraceConfig();
|
|
||||||
persistTraceConfig();
|
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Start: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
traceLogPath = null;
|
|
||||||
traceConfigPath = null;
|
|
||||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
addLogListener(mainLogListener);
|
|
||||||
scheduleAutoDisable();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTraceLogPath(): string | null {
|
|
||||||
if (!traceLogPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(traceLogPath) ? traceLogPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTraceConfigPath(): string | null {
|
|
||||||
if (!traceConfigPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return fs.existsSync(traceConfigPath) ? traceConfigPath : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTraceConfig(): SupportTraceConfig {
|
|
||||||
return { ...traceConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTraceConfig {
|
|
||||||
traceConfig = normalizeTraceConfig({
|
|
||||||
...traceConfig,
|
|
||||||
...patch,
|
|
||||||
updatedAt: logTimestamp()
|
|
||||||
});
|
|
||||||
persistTraceConfig();
|
|
||||||
scheduleAutoDisable();
|
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig as unknown as Record<string, unknown>)}\n`);
|
|
||||||
return getTraceConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
|
|
||||||
const autoDisableAt = enabled && durationMs > 0
|
|
||||||
? new Date(Date.now() + durationMs).toISOString()
|
|
||||||
: null;
|
|
||||||
const next = updateTraceConfig({ enabled, autoDisableAt });
|
|
||||||
appendTraceLine(`${logTimestamp()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logTraceEvent(
|
|
||||||
level: TraceLevel,
|
|
||||||
category: string,
|
|
||||||
message: string,
|
|
||||||
fields?: Record<string, unknown>
|
|
||||||
): void {
|
|
||||||
if (!traceConfig.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (category === "audit" && !traceConfig.includeAudit) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
appendTraceLine(`${logTimestamp()} [${level}] [${category}] ${message}${formatFields(fields)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shutdownTraceLog(): void {
|
|
||||||
removeLogListener(mainLogListener);
|
|
||||||
clearAutoDisableTimer();
|
|
||||||
if (!traceLogPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (flushTimer) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
flushPending();
|
|
||||||
try {
|
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
traceLogPath = null;
|
|
||||||
traceConfigPath = null;
|
|
||||||
traceConfig = { ...DEFAULT_TRACE_CONFIG };
|
|
||||||
}
|
|
||||||
1408
src/main/update.ts
1408
src/main/update.ts
File diff suppressed because it is too large
Load Diff
@ -201,31 +201,19 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
|
|||||||
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 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) => {
|
packages.push({
|
||||||
const firstIndex = currentLinks.findIndex((currentLink) => currentLink === link);
|
|
||||||
return firstIndex >= 0 ? currentFileNames[firstIndex] || "" : "";
|
|
||||||
});
|
|
||||||
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)) {
|
|
||||||
nextPackage.fileNames = fileNames;
|
|
||||||
}
|
|
||||||
packages.push(nextPackage);
|
|
||||||
}
|
}
|
||||||
currentLinks = [];
|
currentLinks = [];
|
||||||
currentFileNames = [];
|
|
||||||
pendingFileName = "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
@ -237,20 +225,9 @@ export function parsePackagesFromLinksText(rawText: string, defaultPackageName:
|
|||||||
if (marker) {
|
if (marker) {
|
||||||
flush();
|
flush();
|
||||||
currentName = String(marker[1] || "").trim();
|
currentName = String(marker[1] || "").trim();
|
||||||
pendingFileName = "";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fileMarker = text.match(/^#\s*file\s*:\s*(.+)$/i);
|
|
||||||
if (fileMarker) {
|
|
||||||
pendingFileName = sanitizeFilename(String(fileMarker[1] || "").trim());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isHttpLink(text)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
currentLinks.push(text);
|
currentLinks.push(text);
|
||||||
currentFileNames.push(pendingFileName);
|
|
||||||
pendingFileName = "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
flush();
|
flush();
|
||||||
@ -271,26 +248,8 @@ export function nowMs(): number {
|
|||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
export function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error(String(signal.reason || "aborted")));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
|
||||||
}, ms);
|
|
||||||
const onAbort = (): void => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
cleanup();
|
|
||||||
reject(new Error(String(signal?.reason || "aborted")));
|
|
||||||
};
|
|
||||||
const cleanup = (): void => {
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
};
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEta(seconds: number): string {
|
export function formatEta(seconds: number): string {
|
||||||
|
|||||||
@ -1,510 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import crypto from "node:crypto";
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
|
|
||||||
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
|
|
||||||
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
|
|
||||||
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
|
|
||||||
// abort-into-child, and "never destroy the only usable audio" safety.
|
|
||||||
//
|
|
||||||
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
|
|
||||||
// the per-package iteration + filename/.DL. rename + logging stays in
|
|
||||||
// download-manager.ts (its existing domain).
|
|
||||||
|
|
||||||
export type GermanAudioMode = "tag" | "first";
|
|
||||||
|
|
||||||
export interface ProbedAudioStream {
|
|
||||||
language: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AudioTrackDecision =
|
|
||||||
| { action: "remux"; audioRelIndex: number; reason: string }
|
|
||||||
| { action: "single"; audioRelIndex: 0; reason: string }
|
|
||||||
| { action: "skip"; reason: string };
|
|
||||||
|
|
||||||
export type VideoProcessAction =
|
|
||||||
| "remuxed"
|
|
||||||
| "kept-single"
|
|
||||||
| "skipped-no-german"
|
|
||||||
| "skipped-no-audio"
|
|
||||||
| "skipped-no-space"
|
|
||||||
| "skipped-no-tool"
|
|
||||||
| "error"
|
|
||||||
| "aborted";
|
|
||||||
|
|
||||||
export interface VideoProcessResult {
|
|
||||||
action: VideoProcessAction;
|
|
||||||
reason: string;
|
|
||||||
keptTrackIndex?: number;
|
|
||||||
totalAudioTracks?: number;
|
|
||||||
audioLanguages?: string[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProcessVideoOptions {
|
|
||||||
mode: GermanAudioMode;
|
|
||||||
cpuPriority?: string;
|
|
||||||
signal?: AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Injection seam so the irreversible file-mutating body (temp -> replace ->
|
|
||||||
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
|
|
||||||
// runner, without spawning real processes. Production passes nothing.
|
|
||||||
export interface ProcessVideoDeps {
|
|
||||||
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
|
|
||||||
runProcess?: typeof runVideoProcess;
|
|
||||||
// Seam for the atomic-replace rename so its failure/recovery path is testable
|
|
||||||
// without provoking a real OS file lock. Production uses renameWithRetry.
|
|
||||||
rename?: (from: string, to: string) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
|
|
||||||
const PROBE_TIMEOUT_MS = 60_000;
|
|
||||||
const STDOUT_CAP = 2 * 1024 * 1024;
|
|
||||||
const STDERR_CAP = 64 * 1024;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pure helpers (no fs / no process) — unit-tested in isolation.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
|
|
||||||
export function stripDualLangMarker(fileName: string): string {
|
|
||||||
const ext = path.extname(fileName);
|
|
||||||
const base = ext ? fileName.slice(0, -ext.length) : fileName;
|
|
||||||
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
|
|
||||||
return stripped + ext;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasDualLangMarker(fileName: string): boolean {
|
|
||||||
return stripDualLangMarker(fileName) !== fileName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isRemuxableVideoFile(fileName: string): boolean {
|
|
||||||
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
// True when the release name explicitly marks it as a German release. Used in
|
|
||||||
// tag mode to fall back to the first audio track (German-first scene convention)
|
|
||||||
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
|
|
||||||
// of skipping. Deliberately requires an explicit german/deutsch token — the
|
|
||||||
// ".DL." marker alone (present on every processed file) is not enough, and a bare
|
|
||||||
// "dubbed" can mean an Italian/French dub, so it must NOT flag a German release.
|
|
||||||
export function looksLikeGermanRelease(fileName: string): boolean {
|
|
||||||
return /(^|[._\s-])(german|deutsch)([._\s-]|$)/i.test(fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isGermanStream(stream: ProbedAudioStream): boolean {
|
|
||||||
const lang = (stream.language || "").toLowerCase().trim();
|
|
||||||
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Free-text title fallback (used when the language tag is missing). Full words
|
|
||||||
// only — the 2-3 letter codes ger/deu are too ambiguous in a title and would
|
|
||||||
// pick the wrong track to keep (which then deletes the real German one).
|
|
||||||
const title = (stream.title || "").toLowerCase();
|
|
||||||
return /\b(german|deutsch)\b/.test(title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decide which audio track to keep. Safety invariant: only ever choose to remux
|
|
||||||
// (which destroys the original) when we are confident; otherwise skip untouched.
|
|
||||||
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
|
|
||||||
const total = streams.length;
|
|
||||||
if (total === 0) {
|
|
||||||
return { action: "skip", reason: "no-audio" };
|
|
||||||
}
|
|
||||||
if (mode === "first") {
|
|
||||||
return total === 1
|
|
||||||
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
|
|
||||||
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
|
|
||||||
}
|
|
||||||
// tag mode
|
|
||||||
const germanPos = streams.findIndex(isGermanStream);
|
|
||||||
if (germanPos >= 0) {
|
|
||||||
return total === 1
|
|
||||||
? { action: "single", audioRelIndex: 0, reason: "single-german" }
|
|
||||||
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
|
|
||||||
}
|
|
||||||
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
|
|
||||||
if (!anyTagged) {
|
|
||||||
// No language metadata at all -> fall back to the script's behavior.
|
|
||||||
return total === 1
|
|
||||||
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
|
|
||||||
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
|
|
||||||
}
|
|
||||||
if (germanRelease) {
|
|
||||||
// Tagged, no German track found, but the release name explicitly says German
|
|
||||||
// -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first
|
|
||||||
// scene convention rather than skipping.
|
|
||||||
return total === 1
|
|
||||||
? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" }
|
|
||||||
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" };
|
|
||||||
}
|
|
||||||
// Tagged, no German track, and nothing says German -> never guess-delete.
|
|
||||||
return { action: "skip", reason: "no-german-track" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(jsonText);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const streams = (parsed as { streams?: unknown }).streams;
|
|
||||||
if (!Array.isArray(streams)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return streams.map((raw) => {
|
|
||||||
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
|
|
||||||
| { language?: unknown; title?: unknown }
|
|
||||||
| undefined;
|
|
||||||
return {
|
|
||||||
language: typeof tags?.language === "string" ? tags.language : "",
|
|
||||||
title: typeof tags?.title === "string" ? tags.title : ""
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFfprobeArgs(input: string): string[] {
|
|
||||||
return [
|
|
||||||
"-v", "error",
|
|
||||||
"-select_streams", "a",
|
|
||||||
"-show_entries", "stream=index:stream_tags=language,title",
|
|
||||||
"-of", "json",
|
|
||||||
input
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
|
|
||||||
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
|
|
||||||
if (opts.keepSubs) {
|
|
||||||
// Optional (not enabled by current settings): keep German subtitle tracks only.
|
|
||||||
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
|
|
||||||
}
|
|
||||||
// Stream-copy and keep metadata (so the kept track's language tag survives;
|
|
||||||
// unlike the original script's -map_metadata -1 which dropped it).
|
|
||||||
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
|
|
||||||
export function computeRemuxTimeoutMs(bytes: number): number {
|
|
||||||
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
|
|
||||||
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
|
|
||||||
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface VideoTooling {
|
|
||||||
ffmpeg: string;
|
|
||||||
ffprobe: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedTooling: VideoTooling | null | undefined;
|
|
||||||
let cachedToolingNullSince = 0;
|
|
||||||
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
|
|
||||||
|
|
||||||
function ffmpegCandidate(): string {
|
|
||||||
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
|
|
||||||
}
|
|
||||||
|
|
||||||
function ffprobeCandidate(): string {
|
|
||||||
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function probeVersion(command: string): Promise<boolean> {
|
|
||||||
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
|
|
||||||
return result.ok && !result.missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
|
|
||||||
if (cachedTooling) {
|
|
||||||
return cachedTooling;
|
|
||||||
}
|
|
||||||
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const ffmpeg = ffmpegCandidate();
|
|
||||||
const ffprobe = ffprobeCandidate();
|
|
||||||
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
|
|
||||||
if (ffmpegOk && ffprobeOk) {
|
|
||||||
cachedTooling = { ffmpeg, ffprobe };
|
|
||||||
return cachedTooling;
|
|
||||||
}
|
|
||||||
cachedTooling = null;
|
|
||||||
cachedToolingNullSince = Date.now();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetVideoToolingCache(): void {
|
|
||||||
cachedTooling = undefined;
|
|
||||||
cachedToolingNullSince = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
|
|
||||||
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface VideoSpawnResult {
|
|
||||||
ok: boolean;
|
|
||||||
aborted: boolean;
|
|
||||||
timedOut: boolean;
|
|
||||||
missing: boolean;
|
|
||||||
exitCode: number | null;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendCapped(buffer: string, text: string, cap: number): string {
|
|
||||||
const next = buffer + text;
|
|
||||||
return next.length > cap ? next.slice(next.length - cap) : next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const numeric = Number(pid || 0);
|
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
|
|
||||||
os.setPriority(numeric, level);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function killChildTree(child: { pid?: number; kill: () => void }): void {
|
|
||||||
const pid = Number(child.pid || 0);
|
|
||||||
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
|
|
||||||
try {
|
|
||||||
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
|
|
||||||
killer.on("error", () => { try { child.kill(); } catch {} });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
child.kill();
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runVideoProcess(
|
|
||||||
command: string,
|
|
||||||
args: string[],
|
|
||||||
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
|
|
||||||
): Promise<VideoSpawnResult> {
|
|
||||||
const { signal, timeoutMs, cpuPriority } = opts;
|
|
||||||
if (signal?.aborted) {
|
|
||||||
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
let timedOut = false;
|
|
||||||
let aborted = false;
|
|
||||||
let timeoutId: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
const child = spawn(command, args, { windowsHide: true });
|
|
||||||
applyChildPriority(child.pid, cpuPriority);
|
|
||||||
|
|
||||||
const onAbort = (): void => {
|
|
||||||
aborted = true;
|
|
||||||
killChildTree(child);
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = (result: VideoSpawnResult): void => {
|
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = null;
|
|
||||||
}
|
|
||||||
if (signal) {
|
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
}
|
|
||||||
resolve(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (timeoutMs && timeoutMs > 0) {
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
killChildTree(child);
|
|
||||||
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
|
|
||||||
}, timeoutMs);
|
|
||||||
}
|
|
||||||
if (signal) {
|
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
|
|
||||||
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
const text = String(error || "");
|
|
||||||
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (aborted) {
|
|
||||||
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (timedOut) {
|
|
||||||
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
|
|
||||||
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
|
|
||||||
// + companion handling + logging is done by the caller (download-manager).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
|
|
||||||
try {
|
|
||||||
const stat = await fs.promises.statfs(dir);
|
|
||||||
return Number(stat.bavail) * Number(stat.bsize);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RENAME_RETRY_DELAYS_MS = [200, 500, 1000];
|
|
||||||
const RENAME_RETRYABLE_CODES = new Set(["EBUSY", "EACCES", "EPERM", "EEXIST"]);
|
|
||||||
|
|
||||||
function delayMs(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows file locks from antivirus, the search indexer, or a media scanner are
|
|
||||||
// transient: a rename that hits EBUSY/EACCES/EPERM/EEXIST often succeeds a moment
|
|
||||||
// later. Retry with backoff before giving up so a momentary lock doesn't abort
|
|
||||||
// the atomic replace and leave the file unprocessed.
|
|
||||||
export async function renameWithRetry(from: string, to: string): Promise<void> {
|
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
|
||||||
try {
|
|
||||||
await fs.promises.rename(from, to);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException)?.code;
|
|
||||||
if (!code || !RENAME_RETRYABLE_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
await delayMs(RENAME_RETRY_DELAYS_MS[attempt]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Short, unique, same-directory sidecar name (never longer than the original file
|
|
||||||
// name) so concurrent packages / retries never collide on a fixed temp name and a
|
|
||||||
// long scene filename + suffix cannot push the path past Windows MAX_PATH.
|
|
||||||
function uniqueTempPath(filePath: string): string {
|
|
||||||
const ext = path.extname(filePath);
|
|
||||||
const token = `${process.pid.toString(36)}${crypto.randomBytes(3).toString("hex")}`;
|
|
||||||
return path.join(path.dirname(filePath), `~rd${token}${ext}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
|
|
||||||
const resolveTool = deps.resolveTooling || resolveVideoTooling;
|
|
||||||
const run = deps.runProcess || runVideoProcess;
|
|
||||||
if (opts.signal?.aborted) {
|
|
||||||
return { action: "aborted", reason: "aborted" };
|
|
||||||
}
|
|
||||||
const tooling = await resolveTool();
|
|
||||||
if (!tooling) {
|
|
||||||
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
|
|
||||||
if (probe.aborted) {
|
|
||||||
return { action: "aborted", reason: "aborted" };
|
|
||||||
}
|
|
||||||
if (!probe.ok) {
|
|
||||||
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const streams = parseFfprobeAudioStreams(probe.stdout);
|
|
||||||
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
|
|
||||||
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
|
|
||||||
if (decision.action === "skip") {
|
|
||||||
return {
|
|
||||||
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
|
|
||||||
reason: decision.reason,
|
|
||||||
totalAudioTracks: streams.length,
|
|
||||||
audioLanguages
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (decision.action === "single") {
|
|
||||||
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// remux path
|
|
||||||
let originalStat: fs.Stats;
|
|
||||||
try {
|
|
||||||
originalStat = await fs.promises.stat(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
|
|
||||||
}
|
|
||||||
const free = await getFreeSpaceBytes(path.dirname(filePath));
|
|
||||||
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
|
|
||||||
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempPath = uniqueTempPath(filePath);
|
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
|
|
||||||
const remux = await run(
|
|
||||||
tooling.ffmpeg,
|
|
||||||
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
|
|
||||||
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
|
|
||||||
);
|
|
||||||
if (remux.aborted) {
|
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return { action: "aborted", reason: "aborted" };
|
|
||||||
}
|
|
||||||
if (!remux.ok) {
|
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
|
|
||||||
if (!tempStat || tempStat.size <= 0) {
|
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
|
|
||||||
}
|
|
||||||
|
|
||||||
const renameOp = deps.rename || renameWithRetry;
|
|
||||||
try {
|
|
||||||
// Atomic replace-over: libuv maps fs.rename to MoveFileEx(REPLACE_EXISTING) on
|
|
||||||
// Windows and rename(2) on POSIX, both atomic on the same volume, so filePath
|
|
||||||
// holds either the full original or the full remux at every instant. Retried
|
|
||||||
// for transient locks. We must NEVER rm the original first (the old fallback
|
|
||||||
// did): an rm-then-failed-rename left zero copies of the file on disk.
|
|
||||||
await renameOp(tempPath, filePath);
|
|
||||||
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
|
|
||||||
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
|
|
||||||
} catch (error) {
|
|
||||||
// Replace failed -> the original is untouched at filePath. Drop the temp only.
|
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
|
|
||||||
}
|
|
||||||
@ -1,325 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
|
|
||||||
export interface WindowsHostEvent {
|
|
||||||
timeCreated: string;
|
|
||||||
id: number;
|
|
||||||
providerName: string;
|
|
||||||
levelDisplayName: string;
|
|
||||||
message: string;
|
|
||||||
bugcheckCode?: string;
|
|
||||||
bugcheckCodeHex?: string;
|
|
||||||
reportId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WindowsHostDumpFile {
|
|
||||||
name: string;
|
|
||||||
fullName: string;
|
|
||||||
length: number;
|
|
||||||
lastWriteTime: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WindowsCrashControlInfo {
|
|
||||||
crashDumpEnabled: number | null;
|
|
||||||
minidumpDir: string;
|
|
||||||
dumpFile: string;
|
|
||||||
overwrite: number | null;
|
|
||||||
logEvent: number | null;
|
|
||||||
autoReboot: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WindowsHostDiagnostics {
|
|
||||||
collectedAt: string;
|
|
||||||
supported: boolean;
|
|
||||||
platform: string;
|
|
||||||
crashControl: WindowsCrashControlInfo | null;
|
|
||||||
recentKernelPower: WindowsHostEvent[];
|
|
||||||
recentWerKernel: WindowsHostEvent[];
|
|
||||||
recentKernelDump: WindowsHostEvent[];
|
|
||||||
recentAppCrashes: WindowsHostEvent[];
|
|
||||||
recentMinidumps: WindowsHostDumpFile[];
|
|
||||||
assessmentHints: string[];
|
|
||||||
errors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_TTL_MS = 15_000;
|
|
||||||
|
|
||||||
let cachedAt = 0;
|
|
||||||
let cachedValue: WindowsHostDiagnostics | null = null;
|
|
||||||
|
|
||||||
function createEmptyDiagnostics(): WindowsHostDiagnostics {
|
|
||||||
return {
|
|
||||||
collectedAt: new Date().toISOString(),
|
|
||||||
supported: process.platform === "win32",
|
|
||||||
platform: process.platform,
|
|
||||||
crashControl: null,
|
|
||||||
recentKernelPower: [],
|
|
||||||
recentWerKernel: [],
|
|
||||||
recentKernelDump: [],
|
|
||||||
recentAppCrashes: [],
|
|
||||||
recentMinidumps: [],
|
|
||||||
assessmentHints: [],
|
|
||||||
errors: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runPowerShellJson(script: string): unknown {
|
|
||||||
const result = spawnSync(
|
|
||||||
process.env.ComSpec && process.env.ComSpec.toLowerCase().includes("pwsh") ? process.env.ComSpec : "powershell.exe",
|
|
||||||
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
|
|
||||||
{
|
|
||||||
encoding: "utf8",
|
|
||||||
timeout: 20_000,
|
|
||||||
windowsHide: true,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const errorText = String(result.stderr || result.stdout || "").trim() || `PowerShell exited with code ${result.status}`;
|
|
||||||
throw new Error(errorText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = String(result.stdout || "").trim();
|
|
||||||
if (!text) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return JSON.parse(text) as unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asString(value: unknown): string {
|
|
||||||
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown): number | null {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEvent(value: unknown): WindowsHostEvent | null {
|
|
||||||
const record = asRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
timeCreated: asString(record.TimeCreated),
|
|
||||||
id: asNumber(record.Id) || 0,
|
|
||||||
providerName: asString(record.ProviderName),
|
|
||||||
levelDisplayName: asString(record.LevelDisplayName),
|
|
||||||
message: asString(record.Message),
|
|
||||||
bugcheckCode: asString(record.BugcheckCode),
|
|
||||||
bugcheckCodeHex: asString(record.BugcheckCodeHex),
|
|
||||||
reportId: asString(record.ReportId)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDumpFile(value: unknown): WindowsHostDumpFile | null {
|
|
||||||
const record = asRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: asString(record.Name),
|
|
||||||
fullName: asString(record.FullName),
|
|
||||||
length: asNumber(record.Length) || 0,
|
|
||||||
lastWriteTime: asString(record.LastWriteTime)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCrashControl(value: unknown): WindowsCrashControlInfo | null {
|
|
||||||
const record = asRecord(value);
|
|
||||||
if (!record) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
crashDumpEnabled: asNumber(record.CrashDumpEnabled),
|
|
||||||
minidumpDir: asString(record.MinidumpDir),
|
|
||||||
dumpFile: asString(record.DumpFile),
|
|
||||||
overwrite: asNumber(record.Overwrite),
|
|
||||||
logEvent: asNumber(record.LogEvent),
|
|
||||||
autoReboot: asNumber(record.AutoReboot)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushHints(diagnostics: WindowsHostDiagnostics): void {
|
|
||||||
if (diagnostics.recentKernelPower.some((entry) => String(entry.bugcheckCode || "").trim() === "0")) {
|
|
||||||
diagnostics.assessmentHints.push("Kernel-Power 41 mit BugcheckCode 0 deutet eher auf Freeze, Watchdog oder harten Reset als auf einen sauber erfassten klassischen BSOD hin.");
|
|
||||||
}
|
|
||||||
if (diagnostics.recentWerKernel.some((entry) => /watchdog/i.test(entry.message))) {
|
|
||||||
diagnostics.assessmentHints.push("WER-Kernel meldet WATCHDOG-Live-Dumps. Das spricht eher fuer Kernel-, Treiber- oder Hardware-Stalls als fuer einen normalen User-Mode-App-Crash.");
|
|
||||||
}
|
|
||||||
if (diagnostics.recentAppCrashes.length === 0) {
|
|
||||||
diagnostics.assessmentHints.push("Keine passenden Application-Error- oder Windows-Error-Reporting-Eintraege fuer den Downloader/Electron in den letzten Tagen gefunden.");
|
|
||||||
}
|
|
||||||
if (diagnostics.recentMinidumps.length === 0) {
|
|
||||||
diagnostics.assessmentHints.push("Keine aktuellen Minidumps gefunden. Falls der Server erneut abstuerzt, sollte geprueft werden, ob Windows den Dump wirklich schreiben darf.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadFromPowerShell(): WindowsHostDiagnostics {
|
|
||||||
const script = String.raw`
|
|
||||||
$ErrorActionPreference = "SilentlyContinue"
|
|
||||||
|
|
||||||
function Convert-EventRecord($eventRecord) {
|
|
||||||
$map = @{}
|
|
||||||
try {
|
|
||||||
[xml]$xml = $eventRecord.ToXml()
|
|
||||||
foreach ($node in $xml.Event.EventData.Data) {
|
|
||||||
if ($node.Name) {
|
|
||||||
$map[$node.Name] = [string]$node.'#text'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
|
|
||||||
$reportId = ""
|
|
||||||
if ([string]$eventRecord.Message -match "ReportId\s+([^,\r\n]+)") {
|
|
||||||
$reportId = $Matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
[PSCustomObject]@{
|
|
||||||
TimeCreated = if ($eventRecord.TimeCreated) { $eventRecord.TimeCreated.ToUniversalTime().ToString("o") } else { "" }
|
|
||||||
Id = [int]$eventRecord.Id
|
|
||||||
ProviderName = [string]$eventRecord.ProviderName
|
|
||||||
LevelDisplayName = [string]$eventRecord.LevelDisplayName
|
|
||||||
Message = [string]$eventRecord.Message
|
|
||||||
BugcheckCode = if ($map.ContainsKey("BugcheckCode")) { [string]$map["BugcheckCode"] } else { "" }
|
|
||||||
BugcheckCodeHex = if ($map.ContainsKey("BugcheckCode") -and [int64]$map["BugcheckCode"] -gt 0) { ("0x{0:X}" -f [int64]$map["BugcheckCode"]) } else { "" }
|
|
||||||
ReportId = $reportId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$startTime = (Get-Date).AddDays(-7)
|
|
||||||
$crashControl = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl"
|
|
||||||
|
|
||||||
$kernelPower = @(
|
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "System"; Id = 41; StartTime = $startTime } -MaxEvents 5 |
|
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
|
||||||
)
|
|
||||||
|
|
||||||
$werKernel = @(
|
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-WerKernel/Operational"; StartTime = $startTime } -MaxEvents 30 |
|
|
||||||
Where-Object { $_.Message -match "WATCHDOG|dump|bugcheck|blue|memory" } |
|
|
||||||
Select-Object -First 10 |
|
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
|
||||||
)
|
|
||||||
|
|
||||||
$kernelDump = @(
|
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Microsoft-Windows-Kernel-Dump/Operational"; StartTime = $startTime } -MaxEvents 20 |
|
|
||||||
Select-Object -First 10 |
|
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
|
||||||
)
|
|
||||||
|
|
||||||
$appCrashes = @(
|
|
||||||
Get-WinEvent -FilterHashtable @{ LogName = "Application"; StartTime = $startTime } -MaxEvents 100 |
|
|
||||||
Where-Object {
|
|
||||||
($_.ProviderName -eq "Application Error" -or $_.ProviderName -eq "Windows Error Reporting") -and
|
|
||||||
($_.Message -match "Real-Debrid-Downloader|electron|node\.exe|main\.js")
|
|
||||||
} |
|
|
||||||
Select-Object -First 10 |
|
|
||||||
ForEach-Object { Convert-EventRecord $_ }
|
|
||||||
)
|
|
||||||
|
|
||||||
$dumpFiles = @()
|
|
||||||
foreach ($dir in @("C:\Windows\Minidump", "C:\Windows\Minidumps")) {
|
|
||||||
if (Test-Path $dir) {
|
|
||||||
$dumpFiles += Get-ChildItem -Path $dir -File |
|
|
||||||
Sort-Object LastWriteTime -Descending |
|
|
||||||
Select-Object -First 10 |
|
|
||||||
ForEach-Object {
|
|
||||||
[PSCustomObject]@{
|
|
||||||
Name = $_.Name
|
|
||||||
FullName = $_.FullName
|
|
||||||
Length = [int64]$_.Length
|
|
||||||
LastWriteTime = $_.LastWriteTimeUtc.ToString("o")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[PSCustomObject]@{
|
|
||||||
CrashControl = [PSCustomObject]@{
|
|
||||||
CrashDumpEnabled = if ($null -ne $crashControl.CrashDumpEnabled) { [int]$crashControl.CrashDumpEnabled } else { $null }
|
|
||||||
MinidumpDir = [string]$crashControl.MinidumpDir
|
|
||||||
DumpFile = [string]$crashControl.DumpFile
|
|
||||||
Overwrite = if ($null -ne $crashControl.Overwrite) { [int]$crashControl.Overwrite } else { $null }
|
|
||||||
LogEvent = if ($null -ne $crashControl.LogEvent) { [int]$crashControl.LogEvent } else { $null }
|
|
||||||
AutoReboot = if ($null -ne $crashControl.AutoReboot) { [int]$crashControl.AutoReboot } else { $null }
|
|
||||||
}
|
|
||||||
RecentKernelPower = @($kernelPower)
|
|
||||||
RecentWerKernel = @($werKernel)
|
|
||||||
RecentKernelDump = @($kernelDump)
|
|
||||||
RecentAppCrashes = @($appCrashes)
|
|
||||||
RecentMinidumps = @($dumpFiles)
|
|
||||||
} | ConvertTo-Json -Depth 6 -Compress
|
|
||||||
`;
|
|
||||||
|
|
||||||
const raw = runPowerShellJson(script);
|
|
||||||
const parsed = asRecord(raw);
|
|
||||||
const diagnostics = createEmptyDiagnostics();
|
|
||||||
diagnostics.crashControl = normalizeCrashControl(parsed?.CrashControl ?? null);
|
|
||||||
diagnostics.recentKernelPower = Array.isArray(parsed?.RecentKernelPower) ? parsed!.RecentKernelPower.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
|
||||||
diagnostics.recentWerKernel = Array.isArray(parsed?.RecentWerKernel) ? parsed!.RecentWerKernel.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
|
||||||
diagnostics.recentKernelDump = Array.isArray(parsed?.RecentKernelDump) ? parsed!.RecentKernelDump.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
|
||||||
diagnostics.recentAppCrashes = Array.isArray(parsed?.RecentAppCrashes) ? parsed!.RecentAppCrashes.map(normalizeEvent).filter(Boolean) as WindowsHostEvent[] : [];
|
|
||||||
diagnostics.recentMinidumps = Array.isArray(parsed?.RecentMinidumps) ? parsed!.RecentMinidumps.map(normalizeDumpFile).filter(Boolean) as WindowsHostDumpFile[] : [];
|
|
||||||
diagnostics.collectedAt = new Date().toISOString();
|
|
||||||
pushHints(diagnostics);
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWindowsHostDiagnostics(forceRefresh = false): WindowsHostDiagnostics {
|
|
||||||
if (!forceRefresh && cachedValue && Date.now() - cachedAt < CACHE_TTL_MS) {
|
|
||||||
return cachedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diagnostics = createEmptyDiagnostics();
|
|
||||||
if (process.platform !== "win32") {
|
|
||||||
diagnostics.assessmentHints.push("Windows-Host-Diagnose ist nur unter Windows verfuegbar.");
|
|
||||||
cachedAt = Date.now();
|
|
||||||
cachedValue = diagnostics;
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const loaded = loadFromPowerShell();
|
|
||||||
cachedAt = Date.now();
|
|
||||||
cachedValue = loaded;
|
|
||||||
return loaded;
|
|
||||||
} catch (error) {
|
|
||||||
diagnostics.errors.push(String(error instanceof Error ? error.message : error));
|
|
||||||
diagnostics.assessmentHints.push("Host-Diagnose konnte nicht vollstaendig geladen werden.");
|
|
||||||
cachedAt = Date.now();
|
|
||||||
cachedValue = diagnostics;
|
|
||||||
return diagnostics;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCachedWindowsHostDiagnostics(): WindowsHostDiagnostics | null {
|
|
||||||
return cachedValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetWindowsHostDiagnosticsCache(): void {
|
|
||||||
cachedAt = 0;
|
|
||||||
cachedValue = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasRecentWindowsMinidumps(): boolean {
|
|
||||||
for (const dir of ["C:\\Windows\\Minidump", "C:\\Windows\\Minidumps"]) {
|
|
||||||
try {
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
if (entries.some((entry) => entry.isFile())) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@ -3,13 +3,9 @@ import {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebridLinkHostLimitInfo,
|
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RendererErrorReport,
|
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -27,8 +23,6 @@ const api: ElectronApi = {
|
|||||||
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),
|
|
||||||
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 }> =>
|
||||||
@ -46,39 +40,22 @@ const api: ElectronApi = {
|
|||||||
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),
|
|
||||||
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),
|
|
||||||
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; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
|
||||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
|
||||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
|
||||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
|
||||||
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
|
||||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
|
||||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
|
||||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
|
||||||
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),
|
|
||||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
|
||||||
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
|
||||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
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),
|
||||||
@ -89,7 +66,6 @@ const api: ElectronApi = {
|
|||||||
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),
|
|
||||||
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);
|
||||||
|
|||||||
2388
src/renderer/App.tsx
2388
src/renderer/App.tsx
File diff suppressed because it is too large
Load Diff
@ -1,94 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
|
||||||
hasError: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catches render-time errors in the component tree so a crash shows a minimal
|
|
||||||
// recovery surface instead of a silent white screen, and forwards the error to
|
|
||||||
// the main process log. Kept deliberately dead-simple and state-independent: an
|
|
||||||
// error inside the error path is how you get a second white screen or a loop.
|
|
||||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
||||||
constructor(props: ErrorBoundaryProps) {
|
|
||||||
super(props);
|
|
||||||
this.state = { hasError: false, message: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
|
||||||
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
|
|
||||||
try {
|
|
||||||
window.rd?.reportRendererError({
|
|
||||||
kind: "react",
|
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
componentStack: info?.componentStack || undefined
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleReload = (): void => {
|
|
||||||
window.location.reload();
|
|
||||||
};
|
|
||||||
|
|
||||||
render(): React.ReactNode {
|
|
||||||
if (!this.state.hasError) {
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
const overlay: React.CSSProperties = {
|
|
||||||
position: "fixed",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: 16,
|
|
||||||
padding: 32,
|
|
||||||
background: "#070b14",
|
|
||||||
color: "#e6edf6",
|
|
||||||
fontFamily: "Segoe UI, system-ui, sans-serif",
|
|
||||||
textAlign: "center"
|
|
||||||
};
|
|
||||||
const pre: React.CSSProperties = {
|
|
||||||
maxWidth: 640,
|
|
||||||
maxHeight: 200,
|
|
||||||
overflow: "auto",
|
|
||||||
padding: 12,
|
|
||||||
background: "#0d1422",
|
|
||||||
border: "1px solid #243049",
|
|
||||||
borderRadius: 6,
|
|
||||||
color: "#ff9a8c",
|
|
||||||
fontSize: 12,
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
textAlign: "left"
|
|
||||||
};
|
|
||||||
const button: React.CSSProperties = {
|
|
||||||
padding: "8px 20px",
|
|
||||||
background: "#2d5cff",
|
|
||||||
color: "#fff",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: 6,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 14
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div style={overlay}>
|
|
||||||
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
|
|
||||||
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
|
|
||||||
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
|
|
||||||
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
|
|
||||||
</p>
|
|
||||||
<pre style={pre}>{this.state.message}</pre>
|
|
||||||
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +1,8 @@
|
|||||||
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 "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
|
|
||||||
// rejections) to the main process log. Without this, a renderer crash leaves no
|
|
||||||
// trace anywhere on an unattended server.
|
|
||||||
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
|
|
||||||
try {
|
|
||||||
window.rd?.reportRendererError(report);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("error", (event) => {
|
|
||||||
reportRendererError({
|
|
||||||
kind: "error",
|
|
||||||
message: event.message || String(event.error || "Unbekannter Fehler"),
|
|
||||||
stack: event.error instanceof Error ? event.error.stack : undefined,
|
|
||||||
source: event.filename || undefined,
|
|
||||||
line: typeof event.lineno === "number" ? event.lineno : undefined,
|
|
||||||
column: typeof event.colno === "number" ? event.colno : undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("unhandledrejection", (event) => {
|
|
||||||
const reason = event.reason;
|
|
||||||
reportRendererError({
|
|
||||||
kind: "unhandledrejection",
|
|
||||||
message: reason instanceof Error ? reason.message : String(reason),
|
|
||||||
stack: reason instanceof Error ? reason.stack : undefined
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
if (!rootElement) {
|
if (!rootElement) {
|
||||||
throw new Error("Root element fehlt");
|
throw new Error("Root element fehlt");
|
||||||
@ -41,8 +10,6 @@ if (!rootElement) {
|
|||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<App />
|
||||||
<App />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../shared/types";
|
import type { PackageEntry } from "../shared/types";
|
||||||
|
|
||||||
const ACTIVE_PACKAGE_STATUSES = new Set<DownloadStatus>(["downloading", "validating", "integrity_check", "extracting"]);
|
|
||||||
|
|
||||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
||||||
const fromIndex = order.indexOf(draggedPackageId);
|
const fromIndex = order.indexOf(draggedPackageId);
|
||||||
@ -25,37 +23,3 @@ export function sortPackageOrderByName(order: string[], packages: Record<string,
|
|||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortPackagesForDisplay(
|
|
||||||
packages: PackageEntry[],
|
|
||||||
itemsById: Record<string, DownloadItem>,
|
|
||||||
running: boolean,
|
|
||||||
autoSortPackagesByProgress: boolean
|
|
||||||
): PackageEntry[] {
|
|
||||||
if (!running || !autoSortPackagesByProgress || packages.length <= 1) {
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
const active: PackageEntry[] = [];
|
|
||||||
const rest: PackageEntry[] = [];
|
|
||||||
|
|
||||||
// Float packages that have an active item to the top, but keep BOTH groups in
|
|
||||||
// their original (queue) order. Earlier this sorted the active group by live
|
|
||||||
// completedRatio/downloadedBytes — which change on every progress tick (every
|
|
||||||
// 150-700ms), so active packages visibly reshuffled the whole time. A package
|
|
||||||
// entering/leaving the active bucket is a real, discrete event (start/finish);
|
|
||||||
// ranking *within* the bucket by live bytes was pure jitter nobody needs.
|
|
||||||
for (const pkg of packages) {
|
|
||||||
const hasActive = pkg.itemIds.some((id) => {
|
|
||||||
const item = itemsById[id];
|
|
||||||
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
|
|
||||||
});
|
|
||||||
(hasActive ? active : rest).push(pkg);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (active.length === 0 || active.length === packages.length) {
|
|
||||||
return packages;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...active, ...rest];
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
import type { SessionState } from "../shared/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drop selected ids whose package OR item no longer exists in the session.
|
|
||||||
* The selection set mixes package and item ids; when entries vanish (delta
|
|
||||||
* removal, backup-driven session swap, completed-cleanup) a stale id would
|
|
||||||
* otherwise inflate the selection count and the "(N)" action labels and keep
|
|
||||||
* "multi" styling alive for ghosts.
|
|
||||||
*
|
|
||||||
* Returns the SAME set instance when nothing changed, so callers can use it
|
|
||||||
* directly as a React state updater without forcing a re-render.
|
|
||||||
*/
|
|
||||||
export function pruneSelection(
|
|
||||||
selected: ReadonlySet<string>,
|
|
||||||
session: Pick<SessionState, "packages" | "items">
|
|
||||||
): Set<string> {
|
|
||||||
if (selected.size === 0) {
|
|
||||||
return selected as Set<string>;
|
|
||||||
}
|
|
||||||
const next = new Set<string>();
|
|
||||||
for (const id of selected) {
|
|
||||||
if (session.packages[id] || session.items[id]) {
|
|
||||||
next.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next.size === selected.size ? (selected as Set<string>) : next;
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
2
src/renderer/vite-env.d.ts
vendored
2
src/renderer/vite-env.d.ts
vendored
@ -1,3 +1,5 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
import type { ElectronApi } from "../shared/preload-api";
|
import type { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
export interface DebridLinkApiKeyEntry {
|
|
||||||
id: string;
|
|
||||||
token: string;
|
|
||||||
index: number;
|
|
||||||
label: string;
|
|
||||||
masked: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
||||||
const FNV64_PRIME = 0x100000001b3n;
|
|
||||||
const FNV64_MASK = 0xffffffffffffffffn;
|
|
||||||
|
|
||||||
function fnv1a64(text: string): string {
|
|
||||||
let hash = FNV64_OFFSET_BASIS;
|
|
||||||
for (const char of text) {
|
|
||||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
|
||||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
|
||||||
}
|
|
||||||
return hash.toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskDebridLinkApiKey(token: string): string {
|
|
||||||
const trimmed = token.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "Nicht hinterlegt";
|
|
||||||
}
|
|
||||||
if (trimmed.length <= 6) {
|
|
||||||
return "*".repeat(trimmed.length);
|
|
||||||
}
|
|
||||||
return `${trimmed.slice(0, 3)}${"*".repeat(Math.max(4, trimmed.length - 6))}${trimmed.slice(-3)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyId(token: string): string {
|
|
||||||
return `dlk_${fnv1a64(token.trim())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyLabel(index: number): string {
|
|
||||||
return `Key ${index + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseDebridLinkApiKeys(raw: string): DebridLinkApiKeyEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const tokens = String(raw || "")
|
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter((token) => {
|
|
||||||
if (seen.has(token)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.add(token);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return tokens.map((token, index) => ({
|
|
||||||
id: getDebridLinkApiKeyId(token),
|
|
||||||
token,
|
|
||||||
index,
|
|
||||||
label: getDebridLinkApiKeyLabel(index),
|
|
||||||
masked: maskDebridLinkApiKey(token)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyIds(raw: string): string[] {
|
|
||||||
return parseDebridLinkApiKeys(raw).map((entry) => entry.id);
|
|
||||||
}
|
|
||||||
@ -6,8 +6,6 @@ export const IPC_CHANNELS = {
|
|||||||
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_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",
|
||||||
@ -22,8 +20,6 @@ export const IPC_CHANNELS = {
|
|||||||
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_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",
|
||||||
@ -32,31 +28,16 @@ export const IPC_CHANNELS = {
|
|||||||
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_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",
|
|
||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
|
||||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
|
||||||
OPEN_SESSION_LOG: "app:open-session-log",
|
OPEN_SESSION_LOG: "app:open-session-log",
|
||||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
|
||||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
|
||||||
OPEN_ITEM_LOG: "app:open-item-log",
|
|
||||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
|
||||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
|
||||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
|
||||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-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",
|
|
||||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
|
||||||
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",
|
||||||
@ -66,6 +47,5 @@ export const IPC_CHANNELS = {
|
|||||||
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"
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
export interface MegaDebridAccountEntry {
|
|
||||||
id: string;
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
index: number;
|
|
||||||
label: string;
|
|
||||||
maskedLogin: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FNV64_OFFSET_BASIS = 0xcbf29ce484222325n;
|
|
||||||
const FNV64_PRIME = 0x100000001b3n;
|
|
||||||
const FNV64_MASK = 0xffffffffffffffffn;
|
|
||||||
|
|
||||||
function fnv1a64(text: string): string {
|
|
||||||
let hash = FNV64_OFFSET_BASIS;
|
|
||||||
for (const char of text) {
|
|
||||||
hash ^= BigInt(char.codePointAt(0) || 0);
|
|
||||||
hash = (hash * FNV64_PRIME) & FNV64_MASK;
|
|
||||||
}
|
|
||||||
return hash.toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountId(login: string): string {
|
|
||||||
return `mda_${fnv1a64(login.trim().toLowerCase())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function maskMegaDebridLogin(login: string): string {
|
|
||||||
const trimmed = login.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
return "Nicht hinterlegt";
|
|
||||||
}
|
|
||||||
if (trimmed.length <= 4) {
|
|
||||||
return `${trimmed[0]}${"*".repeat(trimmed.length - 1)}`;
|
|
||||||
}
|
|
||||||
return `${trimmed.slice(0, 2)}${"*".repeat(Math.max(3, trimmed.length - 4))}${trimmed.slice(-2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountLabel(index: number): string {
|
|
||||||
return `Account ${index + 1}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const lines = String(raw || "")
|
|
||||||
.split(/\n+/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const entries: MegaDebridAccountEntry[] = [];
|
|
||||||
for (const line of lines) {
|
|
||||||
const colonIdx = line.indexOf(":");
|
|
||||||
let login: string;
|
|
||||||
let password: string;
|
|
||||||
if (colonIdx >= 0) {
|
|
||||||
login = line.slice(0, colonIdx).trim();
|
|
||||||
password = line.slice(colonIdx + 1).trim();
|
|
||||||
} else {
|
|
||||||
login = line;
|
|
||||||
password = legacyPassword;
|
|
||||||
}
|
|
||||||
if (!login || !password) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = login.toLowerCase();
|
|
||||||
if (seen.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(key);
|
|
||||||
entries.push({
|
|
||||||
id: getMegaDebridAccountId(login),
|
|
||||||
login,
|
|
||||||
password,
|
|
||||||
index: entries.length,
|
|
||||||
label: getMegaDebridAccountLabel(entries.length),
|
|
||||||
maskedLogin: maskMegaDebridLogin(login)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeMegaDebridAccounts(accounts: { login: string; password: string }[]): string {
|
|
||||||
return accounts
|
|
||||||
.filter((a) => a.login.trim() && a.password.trim())
|
|
||||||
.map((a) => `${a.login.trim()}:${a.password.trim()}`)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountIds(raw: string, legacyPassword = ""): string[] {
|
|
||||||
return parseMegaDebridAccounts(raw, legacyPassword).map((entry) => entry.id);
|
|
||||||
}
|
|
||||||
@ -2,18 +2,12 @@ import type {
|
|||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
|
||||||
DebugSetupCheckResult,
|
|
||||||
DebridLinkHostLimitInfo,
|
|
||||||
DebridProvider,
|
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
RendererErrorReport,
|
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
SupportTraceConfig,
|
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInstallProgress,
|
UpdateInstallProgress,
|
||||||
@ -27,8 +21,6 @@ export interface ElectronApi {
|
|||||||
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>;
|
|
||||||
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[]>;
|
||||||
@ -43,39 +35,22 @@ export interface ElectronApi {
|
|||||||
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 }>;
|
|
||||||
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>;
|
|
||||||
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; message: string }>;
|
||||||
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
|
|
||||||
openLog: () => Promise<void>;
|
openLog: () => Promise<void>;
|
||||||
openAuditLog: () => Promise<void>;
|
|
||||||
openRenameLog: () => Promise<void>;
|
|
||||||
openSessionLog: () => Promise<void>;
|
openSessionLog: () => Promise<void>;
|
||||||
openTraceLog: () => Promise<void>;
|
|
||||||
openPackageLog: (packageId: string) => Promise<void>;
|
|
||||||
openItemLog: (itemId: string) => Promise<void>;
|
|
||||||
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
|
|
||||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
|
||||||
rotateDebugToken: () => Promise<{ path: string }>;
|
|
||||||
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[]>;
|
|
||||||
checkDebridAccounts: () => Promise<DebridAccountStatus[]>;
|
|
||||||
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>;
|
||||||
@ -86,7 +61,6 @@ export interface ElectronApi {
|
|||||||
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;
|
|
||||||
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 +0,0 @@
|
|||||||
import type { AppSettings, DebridProvider } from "./types";
|
|
||||||
|
|
||||||
export type ProviderByteMap = Partial<Record<DebridProvider, number>>;
|
|
||||||
export type DebridLinkKeyByteMap = Record<string, number>;
|
|
||||||
|
|
||||||
type ProviderDailySettings =
|
|
||||||
Pick<AppSettings, "providerDailyLimitBytes" | "providerDailyUsageBytes" | "providerDailyUsageDay">
|
|
||||||
& Partial<Pick<AppSettings, "debridLinkApiKeyDailyLimitBytes" | "debridLinkApiKeyDailyUsageBytes">>
|
|
||||||
& Partial<Pick<AppSettings, "megaDebridDisabledAccountIds" | "megaDebridAccountDailyLimitBytes" | "megaDebridAccountDailyUsageBytes">>;
|
|
||||||
|
|
||||||
type ProviderUsageSettings =
|
|
||||||
ProviderDailySettings
|
|
||||||
& Partial<Pick<AppSettings, "providerTotalUsageBytes" | "debridLinkApiKeyTotalUsageBytes">>
|
|
||||||
& Partial<Pick<AppSettings, "megaDebridAccountTotalUsageBytes">>;
|
|
||||||
|
|
||||||
function normalizePositiveBytes(value: unknown): number {
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.floor(numeric);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderUsageDayKey(epochMs = Date.now()): string {
|
|
||||||
const current = new Date(epochMs);
|
|
||||||
const year = current.getFullYear();
|
|
||||||
const month = String(current.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(current.getDate()).padStart(2, "0");
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyLimitBytes(settings: ProviderDailySettings, provider: DebridProvider): number {
|
|
||||||
return normalizePositiveBytes(settings.providerDailyLimitBytes?.[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number {
|
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return normalizePositiveBytes(settings.providerDailyUsageBytes?.[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderDailyRemainingBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number | null {
|
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
|
||||||
if (limit <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, limit - getProviderDailyUsageBytes(settings, provider, epochMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isProviderDailyLimitReached(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): boolean {
|
|
||||||
const limit = getProviderDailyLimitBytes(settings, provider);
|
|
||||||
return limit > 0 && getProviderDailyUsageBytes(settings, provider, epochMs) >= limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getProviderTotalUsageBytes(settings: ProviderUsageSettings, provider: DebridProvider): number {
|
|
||||||
return normalizePositiveBytes(settings.providerTotalUsageBytes?.[provider]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetProviderDailyUsage(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider?: DebridProvider,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
if (!provider) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
delete nextUsageBytes[provider];
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addProviderDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
byteDelta: number,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "providerDailyUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.providerDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = currentUsageBytes;
|
|
||||||
nextUsageBytes[provider] = normalizePositiveBytes(nextUsageBytes[provider]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
providerDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addProviderTotalUsageBytes(
|
|
||||||
settings: ProviderUsageSettings,
|
|
||||||
provider: DebridProvider,
|
|
||||||
byteDelta: number
|
|
||||||
): Pick<AppSettings, "providerTotalUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const currentUsageBytes = { ...(settings.providerTotalUsageBytes || {}) };
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[provider] = normalizePositiveBytes(currentUsageBytes[provider]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyLimitBytes(settings: ProviderDailySettings, keyId: string): number {
|
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyLimitBytes?.[keyId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number {
|
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyDailyUsageBytes?.[keyId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyDailyRemainingBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number | null {
|
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
|
||||||
if (limit <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return Math.max(0, limit - getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDebridLinkApiKeyDailyLimitReached(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): boolean {
|
|
||||||
const limit = getDebridLinkApiKeyDailyLimitBytes(settings, keyId);
|
|
||||||
return limit > 0 && getDebridLinkApiKeyDailyUsageBytes(settings, keyId, epochMs) >= limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDebridLinkApiKeyTotalUsageBytes(settings: ProviderUsageSettings, keyId: string): number {
|
|
||||||
return normalizePositiveBytes(settings.debridLinkApiKeyTotalUsageBytes?.[keyId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetDebridLinkApiKeyDailyUsage(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId?: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
if (!keyId) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
delete nextUsageBytes[keyId];
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: nextUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addDebridLinkApiKeyDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
keyId: string,
|
|
||||||
byteDelta: number,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "debridLinkApiKeyDailyUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.debridLinkApiKeyDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
debridLinkApiKeyDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addDebridLinkApiKeyTotalUsageBytes(
|
|
||||||
settings: ProviderUsageSettings,
|
|
||||||
keyId: string,
|
|
||||||
byteDelta: number
|
|
||||||
): Pick<AppSettings, "debridLinkApiKeyTotalUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const currentUsageBytes = { ...(settings.debridLinkApiKeyTotalUsageBytes || {}) };
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[keyId] = normalizePositiveBytes(currentUsageBytes[keyId]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
debridLinkApiKeyTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
|
||||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountDailyLimitBytes(settings: ProviderDailySettings, accountId: string): number {
|
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountDailyLimitBytes?.[accountId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
accountId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): number {
|
|
||||||
if (settings.providerDailyUsageDay !== getProviderUsageDayKey(epochMs)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountDailyUsageBytes?.[accountId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isMegaDebridAccountDailyLimitReached(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
accountId: string,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): boolean {
|
|
||||||
const limit = getMegaDebridAccountDailyLimitBytes(settings, accountId);
|
|
||||||
return limit > 0 && getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs) >= limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMegaDebridAccountTotalUsageBytes(settings: ProviderUsageSettings, accountId: string): number {
|
|
||||||
return normalizePositiveBytes(settings.megaDebridAccountTotalUsageBytes?.[accountId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addMegaDebridAccountDailyUsageBytes(
|
|
||||||
settings: ProviderDailySettings,
|
|
||||||
accountId: string,
|
|
||||||
byteDelta: number,
|
|
||||||
epochMs = Date.now()
|
|
||||||
): Pick<AppSettings, "providerDailyUsageDay" | "megaDebridAccountDailyUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const dayKey = getProviderUsageDayKey(epochMs);
|
|
||||||
const currentUsageBytes = settings.providerDailyUsageDay === dayKey
|
|
||||||
? { ...(settings.megaDebridAccountDailyUsageBytes || {}) }
|
|
||||||
: {};
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
providerDailyUsageDay: dayKey,
|
|
||||||
megaDebridAccountDailyUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addMegaDebridAccountTotalUsageBytes(
|
|
||||||
settings: ProviderUsageSettings,
|
|
||||||
accountId: string,
|
|
||||||
byteDelta: number
|
|
||||||
): Pick<AppSettings, "megaDebridAccountTotalUsageBytes"> {
|
|
||||||
const increment = normalizePositiveBytes(byteDelta);
|
|
||||||
const currentUsageBytes = { ...(settings.megaDebridAccountTotalUsageBytes || {}) };
|
|
||||||
if (increment <= 0) {
|
|
||||||
return {
|
|
||||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUsageBytes[accountId] = normalizePositiveBytes(currentUsageBytes[accountId]) + increment;
|
|
||||||
|
|
||||||
return {
|
|
||||||
megaDebridAccountTotalUsageBytes: currentUsageBytes
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -29,7 +29,6 @@ export type DebridFallbackProvider = DebridProvider | "none";
|
|||||||
export type AppTheme = "dark" | "light";
|
export type AppTheme = "dark" | "light";
|
||||||
export type PackagePriority = "high" | "normal" | "low";
|
export type PackagePriority = "high" | "normal" | "low";
|
||||||
export type ExtractCpuPriority = "high" | "middle" | "low";
|
export type ExtractCpuPriority = "high" | "middle" | "low";
|
||||||
export type HistoryRetentionMode = "never" | "session" | "permanent";
|
|
||||||
|
|
||||||
export interface BandwidthScheduleEntry {
|
export interface BandwidthScheduleEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@ -42,28 +41,9 @@ export interface BandwidthScheduleEntry {
|
|||||||
export interface DownloadStats {
|
export interface DownloadStats {
|
||||||
totalDownloaded: number;
|
totalDownloaded: number;
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalFiles?: number;
|
totalFiles: number;
|
||||||
totalFilesSession: number;
|
|
||||||
totalFilesAllTime: number;
|
|
||||||
totalPackages: number;
|
totalPackages: number;
|
||||||
sessionStartedAt: number;
|
sessionStartedAt: number;
|
||||||
appSessionStartedAt: number;
|
|
||||||
sessionRuntimeMs: number;
|
|
||||||
totalRuntimeMs: number;
|
|
||||||
runtimeMeasuredAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DebridAccountStatus {
|
|
||||||
accountId: string;
|
|
||||||
provider: "megadebrid" | "debridlink";
|
|
||||||
label: string;
|
|
||||||
maskedLogin: string;
|
|
||||||
valid: boolean;
|
|
||||||
isPremium: boolean;
|
|
||||||
premiumUntilMs: number | null;
|
|
||||||
email?: string;
|
|
||||||
message: string;
|
|
||||||
checkedAt: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
@ -71,7 +51,6 @@ export interface AppSettings {
|
|||||||
realDebridUseWebLogin: boolean;
|
realDebridUseWebLogin: boolean;
|
||||||
megaLogin: string;
|
megaLogin: string;
|
||||||
megaPassword: string;
|
megaPassword: string;
|
||||||
megaCredentials: string;
|
|
||||||
megaDebridApiEnabled: boolean;
|
megaDebridApiEnabled: boolean;
|
||||||
megaDebridWebEnabled: boolean;
|
megaDebridWebEnabled: boolean;
|
||||||
megaDebridPreferApi: boolean;
|
megaDebridPreferApi: boolean;
|
||||||
@ -83,12 +62,11 @@ export interface AppSettings {
|
|||||||
ddownloadPassword: string;
|
ddownloadPassword: string;
|
||||||
oneFichierApiKey: string;
|
oneFichierApiKey: string;
|
||||||
debridLinkApiKeys: string;
|
debridLinkApiKeys: string;
|
||||||
debridLinkDisabledKeyIds: string[];
|
|
||||||
linkSnappyLogin: string;
|
linkSnappyLogin: string;
|
||||||
linkSnappyPassword: string;
|
linkSnappyPassword: string;
|
||||||
archivePasswordList: string;
|
archivePasswordList: string;
|
||||||
rememberToken: boolean;
|
rememberToken: boolean;
|
||||||
providerOrder: readonly DebridProvider[];
|
providerOrder: DebridProvider[];
|
||||||
providerPrimary: DebridProvider;
|
providerPrimary: DebridProvider;
|
||||||
providerSecondary: DebridFallbackProvider;
|
providerSecondary: DebridFallbackProvider;
|
||||||
providerTertiary: DebridFallbackProvider;
|
providerTertiary: DebridFallbackProvider;
|
||||||
@ -97,8 +75,6 @@ export interface AppSettings {
|
|||||||
packageName: string;
|
packageName: string;
|
||||||
autoExtract: boolean;
|
autoExtract: boolean;
|
||||||
autoRename4sf4sj: boolean;
|
autoRename4sf4sj: boolean;
|
||||||
keepGermanAudioOnly: boolean;
|
|
||||||
germanAudioMode: "tag" | "first";
|
|
||||||
extractDir: string;
|
extractDir: string;
|
||||||
collectMkvToLibrary: boolean;
|
collectMkvToLibrary: boolean;
|
||||||
mkvLibraryDir: string;
|
mkvLibraryDir: string;
|
||||||
@ -125,34 +101,15 @@ export interface AppSettings {
|
|||||||
minimizeToTray: boolean;
|
minimizeToTray: boolean;
|
||||||
theme: AppTheme;
|
theme: AppTheme;
|
||||||
collapseNewPackages: boolean;
|
collapseNewPackages: boolean;
|
||||||
historyRetentionMode: HistoryRetentionMode;
|
|
||||||
accountListShowDetailedDebridLinkKeys: boolean;
|
|
||||||
autoSortPackagesByProgress: boolean;
|
|
||||||
autoSkipExtracted: boolean;
|
autoSkipExtracted: boolean;
|
||||||
hideExtractedItems: boolean;
|
|
||||||
confirmDeleteSelection: boolean;
|
confirmDeleteSelection: boolean;
|
||||||
backupIncludeDownloads: boolean;
|
|
||||||
totalDownloadedAllTime: number;
|
totalDownloadedAllTime: number;
|
||||||
totalCompletedFilesAllTime: number;
|
|
||||||
totalRuntimeAllTimeMs: number;
|
|
||||||
bandwidthSchedules: BandwidthScheduleEntry[];
|
bandwidthSchedules: BandwidthScheduleEntry[];
|
||||||
columnOrder: string[];
|
columnOrder: string[];
|
||||||
extractCpuPriority: ExtractCpuPriority;
|
extractCpuPriority: ExtractCpuPriority;
|
||||||
autoExtractWhenStopped: boolean;
|
autoExtractWhenStopped: boolean;
|
||||||
disabledProviders: DebridProvider[];
|
disabledProviders: DebridProvider[];
|
||||||
hosterRouting: Record<string, DebridProvider>;
|
hosterRouting: Record<string, DebridProvider>;
|
||||||
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
|
|
||||||
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
|
|
||||||
providerTotalUsageBytes: Partial<Record<DebridProvider, number>>;
|
|
||||||
debridLinkApiKeyDailyLimitBytes: Record<string, number>;
|
|
||||||
debridLinkApiKeyDailyUsageBytes: Record<string, number>;
|
|
||||||
debridLinkApiKeyTotalUsageBytes: Record<string, number>;
|
|
||||||
megaDebridDisabledAccountIds: string[];
|
|
||||||
megaDebridAccountDailyLimitBytes: Record<string, number>;
|
|
||||||
megaDebridAccountDailyUsageBytes: Record<string, number>;
|
|
||||||
megaDebridAccountTotalUsageBytes: Record<string, number>;
|
|
||||||
debridAccountStatuses: Record<string, DebridAccountStatus>;
|
|
||||||
providerDailyUsageDay: string;
|
|
||||||
scheduledStartEpochMs: number;
|
scheduledStartEpochMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -161,9 +118,6 @@ export interface DownloadItem {
|
|||||||
packageId: string;
|
packageId: string;
|
||||||
url: string;
|
url: string;
|
||||||
provider: DebridProvider | null;
|
provider: DebridProvider | null;
|
||||||
providerLabel?: string;
|
|
||||||
providerAccountId?: string;
|
|
||||||
providerAccountLabel?: string;
|
|
||||||
status: DownloadStatus;
|
status: DownloadStatus;
|
||||||
retries: number;
|
retries: number;
|
||||||
speedBps: number;
|
speedBps: number;
|
||||||
@ -190,10 +144,8 @@ export interface PackageEntry {
|
|||||||
itemIds: string[];
|
itemIds: string[];
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
priority?: PackagePriority;
|
priority: PackagePriority;
|
||||||
postProcessLabel?: string;
|
postProcessLabel?: string;
|
||||||
downloadStartedAt?: number;
|
|
||||||
downloadCompletedAt?: number;
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
@ -234,19 +186,6 @@ export interface ContainerImportResult {
|
|||||||
source: "dlc";
|
source: "dlc";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RotationEvent {
|
|
||||||
id: string;
|
|
||||||
at: number;
|
|
||||||
level: "INFO" | "WARN" | "ERROR";
|
|
||||||
provider: string;
|
|
||||||
accountLabel: string;
|
|
||||||
event: string;
|
|
||||||
reason?: string;
|
|
||||||
category?: string;
|
|
||||||
cooldownSec?: number;
|
|
||||||
next?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UiSnapshot {
|
export interface UiSnapshot {
|
||||||
settings: AppSettings;
|
settings: AppSettings;
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
@ -260,10 +199,6 @@ export interface UiSnapshot {
|
|||||||
clipboardActive: boolean;
|
clipboardActive: boolean;
|
||||||
reconnectSeconds: number;
|
reconnectSeconds: number;
|
||||||
packageSpeedBps: Record<string, number>;
|
packageSpeedBps: Record<string, number>;
|
||||||
payloadKind?: "full" | "delta";
|
|
||||||
removedItemIds?: string[];
|
|
||||||
removedPackageIds?: string[];
|
|
||||||
rotationEvents?: RotationEvent[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddLinksPayload {
|
export interface AddLinksPayload {
|
||||||
@ -330,8 +265,6 @@ export interface UpdateInstallProgress {
|
|||||||
|
|
||||||
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
||||||
export type AllDebridHostInfoSource = "api" | "web";
|
export type AllDebridHostInfoSource = "api" | "web";
|
||||||
export type DebridLinkHostState = "up" | "down" | "unknown";
|
|
||||||
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
|
|
||||||
|
|
||||||
export interface AllDebridHostInfo {
|
export interface AllDebridHostInfo {
|
||||||
host: string;
|
host: string;
|
||||||
@ -347,27 +280,6 @@ export interface AllDebridHostInfo {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebridLinkHostLimitInfo {
|
|
||||||
keyId: string;
|
|
||||||
keyLabel: string;
|
|
||||||
host: string;
|
|
||||||
fetchedAt: number;
|
|
||||||
trafficCurrentBytes: number | null;
|
|
||||||
trafficMaxBytes: number | null;
|
|
||||||
linksCurrent: number | null;
|
|
||||||
linksMax: number | null;
|
|
||||||
note: string;
|
|
||||||
state: DebridLinkKeyState;
|
|
||||||
stateLabel: string;
|
|
||||||
stateDetail: string;
|
|
||||||
cooldownUntil: number | null;
|
|
||||||
cooldownRemainingMs: number;
|
|
||||||
lastCheckedAt: number | null;
|
|
||||||
hostState: DebridLinkHostState;
|
|
||||||
hostStateLabel: string;
|
|
||||||
hostNote: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParsedHashEntry {
|
export interface ParsedHashEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
algorithm: "crc32" | "md5" | "sha1";
|
algorithm: "crc32" | "md5" | "sha1";
|
||||||
@ -397,92 +309,6 @@ export interface SessionStats {
|
|||||||
queuedDownloads: number;
|
queuedDownloads: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupportTraceConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
includeMainLog: boolean;
|
|
||||||
includeAudit: boolean;
|
|
||||||
logDebugRequests: boolean;
|
|
||||||
autoDisableAt: string | null;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportFileSizeInfo {
|
|
||||||
path: string | null;
|
|
||||||
exists: boolean;
|
|
||||||
bytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportDirectorySizeInfo {
|
|
||||||
path: string;
|
|
||||||
exists: boolean;
|
|
||||||
fileCount: number;
|
|
||||||
bytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportDiskSpaceInfo {
|
|
||||||
path: string;
|
|
||||||
totalBytes: number | null;
|
|
||||||
freeBytes: number | null;
|
|
||||||
freePercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SupportBundleEstimate {
|
|
||||||
estimatedBytes: number;
|
|
||||||
estimatedEntries: number;
|
|
||||||
duplicatedLiveLogBytes: number;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DebugSetupCheckResult {
|
|
||||||
status: "ok" | "warn";
|
|
||||||
enabled: boolean;
|
|
||||||
runtimeBaseDir: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
localOnly: boolean;
|
|
||||||
tokenConfigured: boolean;
|
|
||||||
tokenPath: string;
|
|
||||||
aiManifestPath: string;
|
|
||||||
aiManifestPresent: boolean;
|
|
||||||
traceConfigPath: string | null;
|
|
||||||
traceLogPath: string | null;
|
|
||||||
traceEnabled: boolean;
|
|
||||||
traceAutoDisableAt: string | null;
|
|
||||||
diskSpace: {
|
|
||||||
runtime: SupportDiskSpaceInfo;
|
|
||||||
output: SupportDiskSpaceInfo;
|
|
||||||
extract: SupportDiskSpaceInfo;
|
|
||||||
};
|
|
||||||
logSummary: {
|
|
||||||
totalBytes: number;
|
|
||||||
main: SupportFileSizeInfo;
|
|
||||||
mainBackup: SupportFileSizeInfo;
|
|
||||||
audit: SupportFileSizeInfo;
|
|
||||||
auditBackup: SupportFileSizeInfo;
|
|
||||||
rename: SupportFileSizeInfo;
|
|
||||||
renameBackup: SupportFileSizeInfo;
|
|
||||||
session: SupportFileSizeInfo;
|
|
||||||
trace: SupportFileSizeInfo;
|
|
||||||
traceBackup: SupportFileSizeInfo;
|
|
||||||
sessionLogs: SupportDirectorySizeInfo;
|
|
||||||
packageLogs: SupportDirectorySizeInfo;
|
|
||||||
itemLogs: SupportDirectorySizeInfo;
|
|
||||||
};
|
|
||||||
supportBundle: SupportBundleEstimate;
|
|
||||||
warnings: string[];
|
|
||||||
notes: string[];
|
|
||||||
localUrls: {
|
|
||||||
health: string;
|
|
||||||
meta: string;
|
|
||||||
diagnostics: string;
|
|
||||||
};
|
|
||||||
remoteUrlTemplates: {
|
|
||||||
health: string;
|
|
||||||
meta: string;
|
|
||||||
diagnostics: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -501,13 +327,3 @@ export interface HistoryState {
|
|||||||
entries: HistoryEntry[];
|
entries: HistoryEntry[];
|
||||||
maxEntries: number;
|
maxEntries: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererErrorReport {
|
|
||||||
kind: "error" | "unhandledrejection" | "react";
|
|
||||||
message: string;
|
|
||||||
stack?: string;
|
|
||||||
source?: string;
|
|
||||||
line?: number;
|
|
||||||
column?: number;
|
|
||||||
componentStack?: string;
|
|
||||||
}
|
|
||||||
|
|||||||
335
tasks/lessons.md
335
tasks/lessons.md
@ -1,335 +0,0 @@
|
|||||||
# Lessons
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
|
|
||||||
|
|
||||||
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
|
|
||||||
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **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.
|
|
||||||
164
tasks/todo.md
164
tasks/todo.md
@ -1,164 +0,0 @@
|
|||||||
# Real-Debrid-Downloader — Tasks (Stand 2026-06-08)
|
|
||||||
|
|
||||||
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). Aktuell läuft ein
|
|
||||||
**intensiver Bug-Audit** (User-Goal 2026-06-08, "schaue intensiv nach weiteren Bugs") —
|
|
||||||
Fortschritt direkt unten.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 LAUFEND — Bug-Audit 2026-06-08 (Multi-Agent find→verify, 18 bestätigt)
|
|
||||||
|
|
||||||
Advisor-Triage: **A = einzige echte Daten-Verlust-Notlage** (zerstört echte Datei auf Platte)
|
|
||||||
→ zuerst, ALLEINE Release. **B verifiziert demoted:** applyRetroactiveCleanupPolicy/
|
|
||||||
removePackageFromSession löschen KEINE Platten-Dateien (nur Session/Queue-Einträge + ggf.
|
|
||||||
History-Eintrag) → Queue-Integrität, nicht Daten-Verlust → in v1.7.190-Batch.
|
|
||||||
Sequenz: Release 1 (v1.7.189) = **A allein**; Release 2 (v1.7.190) = B/I,C,D/E,F,G,H,J,L,M,N,O,P,Q.
|
|
||||||
Ein Commit pro Fix, jeder einzeln verifiziert. **K übersprungen** (auto-rename-Reorder,
|
|
||||||
schlechtestes Risiko/Nutzen, kann für diesen User gar nicht feuern).
|
|
||||||
|
|
||||||
### Release 1 — Daten-Verlust-Stopper (v1.7.189, A ALLEIN)
|
|
||||||
- [x] **A** `video-processor.ts` atomic-replace zerstörte bei Windows-Lock BEIDE Kopien
|
|
||||||
(rm(original) VOR bestätigtem Replace + outer-catch rm(temp) → 0 Kopien). **GEFIXT:**
|
|
||||||
atomic replace-over + `renameWithRetry` (EBUSY/EACCES/EPERM/EEXIST, Backoff 200/500/1000ms),
|
|
||||||
rm-first-Fallback entfernt, **unique** Temp-Name (`~rd<pid><rand>`, löst auch C-Kollision).
|
|
||||||
Advisor bestätigt Ansatz besser als bak-dance (kein Missing-File-Window). 3 neue Tests
|
|
||||||
(Recovery + Retry-Pfad), 41 video-processor-Tests grün, tsc=6 (Baseline). Commit 189af22.
|
|
||||||
|
|
||||||
### Release 2 — v1.7.190 (GEFIXT + verifiziert, ein Commit pro Fix)
|
|
||||||
- [x] **L+M** video-processor.ts zu weite Deutsch-Erkennung. isGermanStream Titel-Fallback nur
|
|
||||||
ganze Wörter (ger/deu raus → konnten falsche Spur picken + echte dt. löschen); looksLikeGerman
|
|
||||||
Release 'dubbed' raus (ital./franz. Dub triggerte German-first). 2 Negativtests. Commit 272a41a.
|
|
||||||
- [x] **H** logger.ts flushAsync slice-snapshot korrumpiert bei 1MB-Cap-Trim während await →
|
|
||||||
ungeschriebene Zeilen verloren. Move-snapshot (Buffer auf [] übernehmen) + Requeue bei
|
|
||||||
Schreibfehler. Commit 4432fa2.
|
|
||||||
- [x] **J+Q** download-manager. J: runPackagePostProcessing finally löschte Map-Eintrag ohne
|
|
||||||
Identity-Guard → Abort+Neustart-Race riss neuen Task raus (Waise + Doppel-Lauf); jetzt nur
|
|
||||||
löschen wenn Map noch auf DIESEN Task/Controller zeigt (handle-Objekt wegen TS2454). Q:
|
|
||||||
collectFilesByExtensions filtert `~rd`-Temp-Präfix (crash-verwaiste Teil-Remuxe nie ins
|
|
||||||
Library). Commit 3c33b98.
|
|
||||||
- [x] **P** extractor.ts nested-Resume-Keys (`nested:<name>`) bei jedem extractPackageArchives
|
|
||||||
gepurged → verschachtelte Archive beim Resume neu entpackt; `startsWith("nested:")` im Prune
|
|
||||||
übersprungen. Commit 61a8304.
|
|
||||||
- [x] **B/I** app-controller.ts importBackup settings-only purgte LIVE-Queue (Dateien blieben auf
|
|
||||||
Platte) + rollte Usage-Zähler zurück. Fix: setSettings({suppressRetroactiveCleanup}) +
|
|
||||||
overlayLiveUsageCounters (extrahiert+wiederverwendet, inkl. Key-Filter). Commit dc05b51.
|
|
||||||
|
|
||||||
### Verifiziert KEINE Bugs / bewusst NICHT angefasst (Advisor-Disziplin: erst belegen, dann ändern)
|
|
||||||
- **G** dropItemContribution "subtrahiert Session-Totals nicht" → **KEIN Bug**: Test "keeps
|
|
||||||
cumulative session totals when completed items are removed" kodifiziert die Absicht (Session-
|
|
||||||
Zähler kumulativ, divergieren bewusst von der Item-Map; Retry-Pfad zieht ab, weil neu geladen
|
|
||||||
wird). Fix-Versuch ließ den Test failen → revertiert, Klarstellungs-Kommentar gesetzt.
|
|
||||||
- **N** stripDualLangFromFileName "Kollision" → **bereits geguarded**: existsAsync-Skip verhindert
|
|
||||||
Überschreiben; Remux machte Inhalt eh deutsch-only; collect strippt `.DL.` downstream. Residual
|
|
||||||
= generischer Rename-TOCTOU (in JEDEM Rename-Pfad), kein spezifischer Bug hier.
|
|
||||||
- **D/E** abort-Klassifizierung über signal.reason statt Text → **deferred (Robustheit, kein
|
|
||||||
Live-Bug auf User-Pfad)**. BELEGT: mega-web-fallback normalisiert JEDEN Abort (Timeout UND
|
|
||||||
Cancel) zu `new Error("aborted:mega-web")` → aktueller Guard `/aborted/i && !/timeout/i` FEUERT
|
|
||||||
→ v1.7.187-Cooldown LÄUFT auf dem Web-Pfad (User-Pfad). Einzige Imperfektion: Cancel >8s wird
|
|
||||||
fälschlich gecooled (minor). Empirisch bestätigt: `AbortSignal.any([ac,timeout]).reason?.name===
|
|
||||||
'TimeoutError'` (timeout) vs string/AbortError (cancel) — falls je gebaut: signal.aborted-gaten,
|
|
||||||
reason.name nutzen, Text-Fallback behalten, reason-Test. Hoch-Risiko (kritischer Unrestrict-Pfad
|
|
||||||
JEDES Downloads) → nicht für Robustheit anfassen. API-Pfad-Abort-Text nicht erschöpfend geprüft.
|
|
||||||
- **E** "API 'cancel'-Pfad umgeht" → **nicht real**: kein `'cancel'`-throw im Code gefunden.
|
|
||||||
- **O** classifyAccountFailure abort-Branch tot → **stehen lassen**: tot NUR wegen aktueller
|
|
||||||
Text-Interception; ein signal.aborted-gated D/E würde ihn wiederbeleben. Kein Kosmetik-Churn.
|
|
||||||
- **F** Mega-Web empty-streak Concurrency → **N-shaped, deferred**: Streak wird bei Erfolg (1956)
|
|
||||||
+ Nicht-Limit-Fehler (2005) gecleart; "bis Neustart gesperrt" ist bewusste Tageslimit-Logik,
|
|
||||||
Restart-cleared; Mega-Web single-flight → Concurrency greift nicht. Keine fühlbare Schädigung
|
|
||||||
konstruierbar → keine Park-State-Maschinerie.
|
|
||||||
- **C** → in A subsumiert (unique Temp-Name). **K** übersprungen (auto-rename-Reorder, Risiko≫Nutzen).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟢 OFFEN — Backlog (optional, nie begonnen)
|
|
||||||
|
|
||||||
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187)
|
|
||||||
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
|
|
||||||
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
|
|
||||||
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
|
|
||||||
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
|
|
||||||
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
|
|
||||||
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
|
|
||||||
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
|
|
||||||
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
|
|
||||||
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
|
|
||||||
|
|
||||||
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)**
|
|
||||||
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen
|
|
||||||
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert.
|
|
||||||
|
|
||||||
**Verifizierter Mechanismus (Code):**
|
|
||||||
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s →
|
|
||||||
weiter zu Account 2. Account 2 → `aborted:debrid`.
|
|
||||||
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein →
|
|
||||||
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
|
|
||||||
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
|
|
||||||
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
|
|
||||||
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
|
|
||||||
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
|
|
||||||
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
|
|
||||||
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
|
|
||||||
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
|
|
||||||
|
|
||||||
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich"
|
|
||||||
(11:51:45–11:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 —
|
|
||||||
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
|
|
||||||
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
|
|
||||||
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
|
|
||||||
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
|
|
||||||
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
|
|
||||||
|
|
||||||
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap.
|
|
||||||
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
|
|
||||||
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
|
|
||||||
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
|
|
||||||
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
|
|
||||||
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
|
|
||||||
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
|
|
||||||
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
|
|
||||||
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
|
|
||||||
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
|
|
||||||
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
|
|
||||||
|
|
||||||
### Features / UX (nach ROI)
|
|
||||||
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
|
|
||||||
|
|
||||||
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — S–M. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
|
|
||||||
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — S–M. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
|
|
||||||
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
|
|
||||||
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
|
|
||||||
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
|
|
||||||
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
|
|
||||||
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
|
|
||||||
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — S–M. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
|
|
||||||
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
|
|
||||||
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
|
|
||||||
|
|
||||||
### Design-Richtung (Entscheidung steht aus)
|
|
||||||
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
|
|
||||||
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
|
|
||||||
|
|
||||||
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
|
|
||||||
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
|
|
||||||
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
|
|
||||||
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
|
|
||||||
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
|
|
||||||
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
|
|
||||||
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory)
|
|
||||||
|
|
||||||
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging
|
|
||||||
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
|
|
||||||
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
|
|
||||||
- **Account-Rotation-Overhaul** → v1.7.164–168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
|
|
||||||
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
|
|
||||||
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
|
|
||||||
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
|
|
||||||
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
|
|
||||||
|
|
||||||
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente)
|
|
||||||
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar
|
|
||||||
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai)
|
|
||||||
@ -1,161 +0,0 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
||||||
import { checkMegaDebridAccount, checkDebridLinkKey, checkAllDebridAccounts } from "../src/main/account-check";
|
|
||||||
import type { MegaDebridAccountEntry } from "../src/shared/mega-debrid-accounts";
|
|
||||||
import type { DebridLinkApiKeyEntry } from "../src/shared/debrid-link-keys";
|
|
||||||
import type { AppSettings } from "../src/shared/types";
|
|
||||||
|
|
||||||
function megaAccount(login = "user@example.com"): MegaDebridAccountEntry {
|
|
||||||
return { id: "mda_test", login, password: "pw", index: 0, label: "Account 1", maskedLogin: "us**le" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function debridLinkKey(token = "tok_abcdef"): DebridLinkApiKeyEntry {
|
|
||||||
return { id: "dlk_test", token, index: 0, label: "Key 1", masked: "tok***def" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockFetchOnce(status: number, body: unknown): void {
|
|
||||||
const text = typeof body === "string" ? body : JSON.stringify(body);
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
text: async () => text
|
|
||||||
})) as unknown as typeof fetch);
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOW = 1_700_000_000_000;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkMegaDebridAccount", () => {
|
|
||||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
|
||||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60;
|
|
||||||
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(true);
|
|
||||||
expect(st.isPremium).toBe(true);
|
|
||||||
expect(st.premiumUntilMs).toBe(futureSec * 1000);
|
|
||||||
expect(st.email).toBe("a@b.de");
|
|
||||||
expect(st.message).toMatch(/Premium noch/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports valid but NOT premium when vip_end is in the past", async () => {
|
|
||||||
const pastSec = Math.floor(NOW / 1000) - 1000;
|
|
||||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: String(pastSec) });
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(true);
|
|
||||||
expect(st.isPremium).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports valid but no premium when vip_end is 0/missing", async () => {
|
|
||||||
mockFetchOnce(200, { response_code: "ok", token: "t", vip_end: "0" });
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(true);
|
|
||||||
expect(st.isPremium).toBe(false);
|
|
||||||
expect(st.premiumUntilMs).toBe(0);
|
|
||||||
expect(st.message).toMatch(/Kein Premium/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports invalid login when response_code != ok", async () => {
|
|
||||||
mockFetchOnce(200, { response_code: "error", response_text: "bad login" });
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(false);
|
|
||||||
expect(st.isPremium).toBe(false);
|
|
||||||
expect(st.message).toMatch(/Ungueltiger Login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports invalid on HTTP error", async () => {
|
|
||||||
mockFetchOnce(500, "server error");
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never throws on network error — returns a failed status", async () => {
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("ECONNRESET"); }) as unknown as typeof fetch);
|
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(false);
|
|
||||||
expect(st.message).toMatch(/Pruefung fehlgeschlagen/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkDebridLinkKey", () => {
|
|
||||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
|
||||||
const premiumLeft = 60 * 24 * 60 * 60;
|
|
||||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(true);
|
|
||||||
expect(st.isPremium).toBe(true);
|
|
||||||
expect(st.premiumUntilMs).toBe(NOW + premiumLeft * 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports valid but free (premiumLeft 0, accountType 0)", async () => {
|
|
||||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 0, premiumLeft: 0 } });
|
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(true);
|
|
||||||
expect(st.isPremium).toBe(false);
|
|
||||||
expect(st.message).toMatch(/Free/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports invalid key on HTTP 401", async () => {
|
|
||||||
mockFetchOnce(401, { success: false, error: "badToken" });
|
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(false);
|
|
||||||
expect(st.message).toMatch(/Ungueltiger API-Key/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports invalid key when success=false", async () => {
|
|
||||||
mockFetchOnce(200, { success: false, error: "badToken" });
|
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
|
||||||
expect(st.valid).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("checkAllDebridAccounts", () => {
|
|
||||||
it("returns empty array when nothing configured", async () => {
|
|
||||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: "" } as unknown as AppSettings;
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checks every configured mega account + debrid-link key", async () => {
|
|
||||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
||||||
if (String(url).includes("mega-debrid")) {
|
|
||||||
return { ok: true, status: 200, text: async () => JSON.stringify({ response_code: "ok", token: "t", vip_end: String(futureSec) }) };
|
|
||||||
}
|
|
||||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
|
||||||
}) as unknown as typeof fetch);
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
megaCredentials: "a@b.de:pw1\nc@d.de:pw2",
|
|
||||||
megaPassword: "",
|
|
||||||
debridLinkApiKeys: "key1\nkey2\nkey3"
|
|
||||||
} as unknown as AppSettings;
|
|
||||||
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
|
||||||
expect(result).toHaveLength(5);
|
|
||||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
|
||||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
|
||||||
expect(result.every((r) => r.valid)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps concurrency (never more than 4 in flight) and preserves result order", async () => {
|
|
||||||
let inFlight = 0;
|
|
||||||
let maxInFlight = 0;
|
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
||||||
inFlight += 1;
|
|
||||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
||||||
inFlight -= 1;
|
|
||||||
return { ok: true, status: 200, text: async () => JSON.stringify({ success: true, value: { accountType: 1, premiumLeft: 1000 } }) };
|
|
||||||
}) as unknown as typeof fetch);
|
|
||||||
|
|
||||||
const keys = Array.from({ length: 9 }, (_, i) => `key_${i}`).join("\n");
|
|
||||||
const settings = { megaCredentials: "", megaPassword: "", debridLinkApiKeys: keys } as unknown as AppSettings;
|
|
||||||
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
|
||||||
expect(result).toHaveLength(9);
|
|
||||||
expect(maxInFlight).toBeLessThanOrEqual(4);
|
|
||||||
result.forEach((r, i) => expect(r.label).toBe(`Key ${i + 1}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { logAccountRotation, runWithRotationItemSink, getRecentRotationEvents } from "../src/main/account-rotation-log";
|
|
||||||
import type { RotationEvent } from "../src/shared/types";
|
|
||||||
|
|
||||||
describe("rotation item-sink (AsyncLocalStorage)", () => {
|
|
||||||
it("routes the FULL rotation trail (incl. TEST) to the active item sink", async () => {
|
|
||||||
const captured: RotationEvent[] = [];
|
|
||||||
await runWithRotationItemSink((ev) => captured.push(ev), async () => {
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "TEST", { link: "x" });
|
|
||||||
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = captured.map((e) => e.event);
|
|
||||||
expect(events).toEqual(["TEST", "FAILED", "TEST", "OK"]);
|
|
||||||
const failed = captured.find((e) => e.event === "FAILED");
|
|
||||||
expect(failed?.reason).toBe("Timeout");
|
|
||||||
expect(failed?.next).toBe("Account 2/3 (cd**zw)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not leak events to the sink outside the run() scope", () => {
|
|
||||||
const captured: RotationEvent[] = [];
|
|
||||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
|
||||||
expect(captured).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates two parallel item sinks (no cross-attribution)", async () => {
|
|
||||||
const a: RotationEvent[] = [];
|
|
||||||
const b: RotationEvent[] = [];
|
|
||||||
await Promise.all([
|
|
||||||
runWithRotationItemSink((ev) => a.push(ev), async () => {
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "TEST");
|
|
||||||
await new Promise((r) => setTimeout(r, 10));
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 1 (a)", "OK");
|
|
||||||
}),
|
|
||||||
runWithRotationItemSink((ev) => b.push(ev), async () => {
|
|
||||||
logAccountRotation("INFO", "Debrid-Link", "Key 1 (b)", "TEST");
|
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
|
||||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
|
||||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
|
||||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
|
||||||
expect(b.map((e) => e.event)).toEqual(["TEST", "FAILED"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still feeds the global UI ring (outcomes only, TEST filtered)", () => {
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
|
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
|
||||||
const ring = getRecentRotationEvents(10);
|
|
||||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
|
||||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownAuditLog();
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("audit-log", () => {
|
|
||||||
it("writes audit events to the audit log", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initAuditLog(baseDir);
|
|
||||||
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
|
|
||||||
|
|
||||||
const logPath = getAuditLogPath();
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
expect(fs.existsSync(logPath!)).toBe(true);
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Audit-Log Start");
|
|
||||||
expect(content).toContain("Settings changed");
|
|
||||||
expect(content).toContain("changedKeys");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rotates oversized audit logs on startup", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
const oversizedPath = path.join(baseDir, "audit.log");
|
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
|
||||||
|
|
||||||
initAuditLog(baseDir);
|
|
||||||
|
|
||||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
|
||||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
|
||||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
|
||||||
expect(content).toContain("Audit-Log Start");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,223 +6,9 @@ import {
|
|||||||
ensureRepackToken,
|
ensureRepackToken,
|
||||||
buildAutoRenameBaseName,
|
buildAutoRenameBaseName,
|
||||||
buildAutoRenameBaseNameFromFolders,
|
buildAutoRenameBaseNameFromFolders,
|
||||||
buildAutoRenameBaseNameFromFoldersWithOptions,
|
buildAutoRenameBaseNameFromFoldersWithOptions
|
||||||
hasMeaningfulSeriesPrefix,
|
|
||||||
looksLikeObfuscatedSceneFileName,
|
|
||||||
decideAutoRenameBaseName,
|
|
||||||
isBonusContent
|
|
||||||
} from "../src/main/download-manager";
|
} from "../src/main/download-manager";
|
||||||
|
|
||||||
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
|
|
||||||
|
|
||||||
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
|
|
||||||
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
|
|
||||||
const folders = [
|
|
||||||
"Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV",
|
|
||||||
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
|
|
||||||
];
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
folders,
|
|
||||||
source,
|
|
||||||
"tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720",
|
|
||||||
folders[0],
|
|
||||||
folders[1]
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the clean name from a SEASON-only folder by injecting the source episode token (Herzflimmern S03E14)", () => {
|
|
||||||
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720.mkv";
|
|
||||||
const seasonFolder = "Herzflimmern.die.Klinik.am.See.S03.German.720p.Webrip.x264-TVARCHiV";
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
[seasonFolder],
|
|
||||||
source,
|
|
||||||
"tvarchiv.herzflimmern.die.klinik.am.see.s03e14-720",
|
|
||||||
seasonFolder,
|
|
||||||
seasonFolder
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe("Herzflimmern.die.Klinik.am.See.S03E14.German.720p.Webrip.x264-TVARCHiV");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("derives the clean name for the Fritzie S04 files that sat raw in Downloader Unfertig (4sf- scene group, season folder)", () => {
|
|
||||||
const source = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
|
|
||||||
const seasonFolder = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
[seasonFolder],
|
|
||||||
source,
|
|
||||||
"4sf-fritzie.himmel.muss.warten.web.7p-s04e01",
|
|
||||||
seasonFolder,
|
|
||||||
seasonFolder
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe("Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent: an already-clean file in its clean folder derives to the same name (no worse-than-now)", () => {
|
|
||||||
const clean = "Herzflimmern.Die.Klinik.am.See.S07E02.German.720p.Webrip.x264-TVARCHiV";
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
[clean, "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"],
|
|
||||||
`${clean}.mkv`,
|
|
||||||
clean,
|
|
||||||
clean,
|
|
||||||
"Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe(clean);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
|
|
||||||
"awa-diethundermans02e16hd.mkv",
|
|
||||||
"awa-diethundermans02e16hd",
|
|
||||||
"Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake",
|
|
||||||
"Die.Thundermans.S02.GERMAN.x264-aWake"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toContain("S02E01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
|
|
||||||
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
|
|
||||||
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f",
|
|
||||||
"The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON",
|
|
||||||
"The.Royals.2015.S01.German.DL.720p.BluRay.x264-iNTENTiON"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
expect(decision.kind === "skip" && decision.reason).toBe("token-mismatch");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips (no-target) when no folder candidate yields a usable scene name", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
["random user folder", "another plain dir"],
|
|
||||||
"some.file.mkv",
|
|
||||||
"some.file",
|
|
||||||
"random user folder",
|
|
||||||
"another plain dir"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
|
|
||||||
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
[epFolder, "scn2-cstl7"],
|
|
||||||
"castle.s08e02.german.dl.720p.web.h264-idtv_int.mkv",
|
|
||||||
"castle.s08e02.german.dl.720p.web.h264-idtv_int",
|
|
||||||
epFolder,
|
|
||||||
"scn2-cstl7"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe(epFolder);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
|
|
||||||
const folders = [
|
|
||||||
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
|
|
||||||
"kig.hdtv.7p-001",
|
|
||||||
"Kreuzfahrt ins Glück S01"
|
|
||||||
];
|
|
||||||
const decision = decideAutoRenameBaseName(folders, "bet_kig_01_hdt.mkv", "bet_kig_01_hdt", folders[0], folders[2]);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("complete-folder fallback fires on CODEC alone (no resolution token — DVDRip/XviD class)", () => {
|
|
||||||
const folders = [
|
|
||||||
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.DVDRip.x264-BET",
|
|
||||||
"Kreuzfahrt ins Glück S01"
|
|
||||||
];
|
|
||||||
const decision = decideAutoRenameBaseName(folders, "bet_kig_01.mkv", "bet_kig_01", folders[0], folders[1]);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(decision.kind === "rename" && decision.baseName).toBe(folders[0]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("complete-folder fallback does NOT fire when the source HAS an episode token (generic pack stays no-target)", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(
|
|
||||||
["Mega-Direct-Pack"],
|
|
||||||
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv",
|
|
||||||
"Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT",
|
|
||||||
"Mega-Direct-Pack",
|
|
||||||
"Mega-Direct-Pack"
|
|
||||||
);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasMeaningfulSeriesPrefix", () => {
|
|
||||||
it("recognizes a real series name before the season token", () => {
|
|
||||||
expect(hasMeaningfulSeriesPrefix("Desperate.Housewives.S01.Synced.DL.720p.WEB-DL.AC3.h264")).toBe(true);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake")).toBe(true);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("Mistresses.2013.S02.GERMAN.DL.720p.WEB.x264-TSCC")).toBe(true);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("show.name.s01e01.720p")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects generic season-label folders without a series name", () => {
|
|
||||||
expect(hasMeaningfulSeriesPrefix("S01 Complete")).toBe(false);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("S02")).toBe(false);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("S01E01 Complete")).toBe(false);
|
|
||||||
expect(hasMeaningfulSeriesPrefix(".S01.bla")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when there is no season token at all", () => {
|
|
||||||
expect(hasMeaningfulSeriesPrefix("Some Random Folder")).toBe(false);
|
|
||||||
expect(hasMeaningfulSeriesPrefix("")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("looksLikeObfuscatedSceneFileName", () => {
|
|
||||||
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles edge cases (empty, very short)", () => {
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("")).toBe(true);
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("a.mkv")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats long dotted names as scene-style even with few markers", () => {
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractEpisodeToken (extended formats)", () => {
|
|
||||||
it("recognizes the older xX format (capped at 2 episode digits)", () => {
|
|
||||||
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
|
|
||||||
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
|
|
||||||
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
|
|
||||||
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
|
|
||||||
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not falsely match resolution tokens like 1080x720", () => {
|
|
||||||
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
|
|
||||||
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
|
|
||||||
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
|
|
||||||
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
|
|
||||||
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not falsely match common aspect ratios like 1920x1080", () => {
|
|
||||||
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractEpisodeToken", () => {
|
describe("extractEpisodeToken", () => {
|
||||||
it("extracts S01E01 from standard scene format", () => {
|
it("extracts S01E01 from standard scene format", () => {
|
||||||
expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");
|
expect(extractEpisodeToken("show.name.s01e01.720p")).toBe("S01E01");
|
||||||
@ -292,10 +78,6 @@ describe("extractEpisodeToken", () => {
|
|||||||
it("extracts double episode with single-digit numbers", () => {
|
it("extracts double episode with single-digit numbers", () => {
|
||||||
expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02");
|
expect(extractEpisodeToken("show-s1e1e2-720p")).toBe("S01E01E02");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts episode when title and season token are joined", () => {
|
|
||||||
expect(extractEpisodeToken("mdgp-carters02e01-720p")).toBe("S02E01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("applyEpisodeTokenToFolderName", () => {
|
describe("applyEpisodeTokenToFolderName", () => {
|
||||||
@ -467,6 +249,7 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Edge cases
|
||||||
it("handles 2160p quality token", () => {
|
it("handles 2160p quality token", () => {
|
||||||
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
|
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
|
||||||
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
|
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
|
||||||
@ -484,10 +267,12 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
|
|
||||||
it("handles high season and episode numbers", () => {
|
it("handles high season and episode numbers", () => {
|
||||||
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
|
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
|
||||||
|
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S99E999");
|
expect(result!).toContain("S99E999");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Real-world scene release patterns
|
||||||
it("real-world: German series with dots", () => {
|
it("real-world: German series with dots", () => {
|
||||||
const result = buildAutoRenameBaseName(
|
const result = buildAutoRenameBaseName(
|
||||||
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
|
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
|
||||||
@ -552,13 +337,18 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
|
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bug-hunting edge cases
|
||||||
it("source filename extension is not included in episode detection", () => {
|
it("source filename extension is not included in episode detection", () => {
|
||||||
|
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
|
||||||
|
// so .mkv should not interfere, but let's verify with an actual extension
|
||||||
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
|
||||||
|
// "mkv" should not be treated as part of the filename match
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S01E01");
|
expect(result!).toContain("S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not match episode-like patterns in codec strings", () => {
|
it("does not match episode-like patterns in codec strings", () => {
|
||||||
|
// h.265 has digits but should not be confused with episode tokens
|
||||||
const token = extractEpisodeToken("show.s01e01.h.265");
|
const token = extractEpisodeToken("show.s01e01.h.265");
|
||||||
expect(token).toBe("S01E01");
|
expect(token).toBe("S01E01");
|
||||||
});
|
});
|
||||||
@ -576,19 +366,23 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
"Show.S01E05.720p-4sf",
|
"Show.S01E05.720p-4sf",
|
||||||
"show.s01e05.720p"
|
"show.s01e05.720p"
|
||||||
);
|
);
|
||||||
|
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
|
||||||
expect(result).toBe("Show.S01E05.720p-4sf");
|
expect(result).toBe("Show.S01E05.720p-4sf");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles folder with only -4sf suffix (edge case)", () => {
|
it("handles folder with only -4sf suffix (edge case)", () => {
|
||||||
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
|
||||||
|
// Extreme edge case - sanitizeFilename trims leading dots
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S01E01");
|
expect(result!).toContain("S01E01");
|
||||||
expect(result!).toContain("-4sf");
|
expect(result!).toContain("-4sf");
|
||||||
expect(result!).not.toContain(".S01E01.S01E01");
|
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes special characters from result", () => {
|
it("sanitizes special characters from result", () => {
|
||||||
|
// sanitizeFilename should strip dangerous chars
|
||||||
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
|
||||||
|
// The colon should be sanitized away
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).not.toContain(":");
|
expect(result!).not.toContain(":");
|
||||||
});
|
});
|
||||||
@ -753,28 +547,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS");
|
expect(result).toBe("Lethal.Weapon.S02E11.German.DD51.Dubbed.DL.720p.AmazonHD.x264-TVS");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps compact code 319a to episode 19 in season 3 folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
[
|
|
||||||
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
|
|
||||||
],
|
|
||||||
"hrs-bpol.hdtv.7p-319a",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E19.GERMAN.AC3.720p.HDTV.x264-hrs");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps compact code 319b to next episode in season 3 folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
[
|
|
||||||
"Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03.GERMAN.AC3.720p.HDTV.x264-hrs"
|
|
||||||
],
|
|
||||||
"hrs-bpol.hdtv.7p-319b",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Die.Bergpolizei.-.Ganz.nah.am.Himmel.S03E20.GERMAN.AC3.720p.HDTV.x264-hrs");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps episode-only token e01 via season folder hint and keeps REPACK", () => {
|
it("maps episode-only token e01 via season folder hint and keeps REPACK", () => {
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
[
|
[
|
||||||
@ -852,6 +624,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
|
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
|
||||||
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
|
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
["Mystery Road S02"],
|
["Mystery Road S02"],
|
||||||
@ -879,6 +652,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
"myst.road.de.dl.hdtv.7p-s02e05",
|
"myst.road.de.dl.hdtv.7p-s02e05",
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
);
|
);
|
||||||
|
// Should use the scene-group folder (hrs), not the custom one
|
||||||
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
|
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -917,173 +691,4 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
);
|
);
|
||||||
expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW");
|
expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames Carter when source joins title and season token", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["Carter.S02.GERMAN.DL.720p.HDTV.x264-MDGP"],
|
|
||||||
"mdgp-carters02e01-720p",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Carter.S02E01.GERMAN.DL.720p.HDTV.x264-MDGP");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames abbreviated source bupr.de.dl.web.7p-s01e03 via season folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["Burning.Promise.S01.GERMAN.DL.720p.WEB.H264-WvF"],
|
|
||||||
"bupr.de.dl.web.7p-s01e03",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Burning.Promise.S01E03.GERMAN.DL.720p.WEB.H264-WvF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames abbreviated 4SF source amilllt.de.dl.web.7p-s03e10 via season folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["A.Million.Little.Things.S03.GERMAN.DL.720p.WEB.H264-4SF"],
|
|
||||||
"4sf-amilllt.de.dl.web.7p-s03e10",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("A.Million.Little.Things.S03E10.GERMAN.DL.720p.WEB.H264-4SF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames abbreviated source jkl.web.7p-s01e13 via season folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["9JKL.S01.GERMAN.720p.WEB.x264-WvF"],
|
|
||||||
"jkl.web.7p-s01e13",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("9JKL.S01E13.GERMAN.720p.WEB.x264-WvF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames abbreviated source jkl.web.7p-s01e14 via season folder", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["9JKL.S01.GERMAN.720p.WEB.x264-WvF"],
|
|
||||||
"jkl.web.7p-s01e14",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("9JKL.S01E14.GERMAN.720p.WEB.x264-WvF");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("documents malformed package name (S01GERMAN) limitation", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
[
|
|
||||||
"3MH.web.7p-101",
|
|
||||||
"Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
|
|
||||||
],
|
|
||||||
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
if (result !== null) {
|
|
||||||
expect(typeof result).toBe("string");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isBonusContent (numbered episodes are never bonus)", () => {
|
|
||||||
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
|
|
||||||
|
|
||||||
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
|
|
||||||
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
|
||||||
const fp = `${pkgDir}/${name}/${name}.mkv`;
|
|
||||||
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("covers further bonus-word episode titles with a token", () => {
|
|
||||||
for (const title of ["Special", "Featurette", "Outtakes", "Bloopers", "Making.Of"]) {
|
|
||||||
const name = `Show.S04E07.${title}.GERMAN.720p.WEB.x264-GRP`;
|
|
||||||
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("STILL treats genuine extras WITHOUT an episode token as bonus", () => {
|
|
||||||
for (const name of [
|
|
||||||
"Show.Making.Of.GERMAN.720p.WEB.x264-GRP",
|
|
||||||
"Show.Behind.The.Scenes.GERMAN-GRP",
|
|
||||||
"Some.Interview.With.Cast"
|
|
||||||
]) {
|
|
||||||
expect(isBonusContent(`${pkgDir}/${name}.mkv`, pkgDir, name)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a token-bearing file inside an Extras subfolder is still kept (numbered episode wins)", () => {
|
|
||||||
const name = "Show.S04E19.Interview.GROUP";
|
|
||||||
const fp = `${pkgDir}/Extras/${name}/${name}.mkv`;
|
|
||||||
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a token-less file inside an Extras subfolder is bonus", () => {
|
|
||||||
const fp = `${pkgDir}/Extras/Making.Of.mkv`;
|
|
||||||
expect(isBonusContent(fp, pkgDir, "Making.Of")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("complete episode folder WITHOUT group suffix (codec/resolution only)", () => {
|
|
||||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
|
||||||
|
|
||||||
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
|
|
||||||
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
|
||||||
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
|
||||||
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps multi-part letters a/b distinct (Teil.1 vs Teil.2 do NOT collide)", () => {
|
|
||||||
const fa = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
|
||||||
const fb = "Fluss-Monster.S04E08b.Am.Essequibo.Teil.2.German.DOKU.SATRiP.XviD";
|
|
||||||
const da = decideAutoRenameBaseName([fa, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
|
||||||
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
|
|
||||||
expect(da).toEqual({ kind: "rename", baseName: fa });
|
|
||||||
expect(db).toEqual({ kind: "rename", baseName: fb });
|
|
||||||
expect((da as any).baseName).not.toBe((db as any).baseName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the previously-working group-suffix folder still works (no regression)", () => {
|
|
||||||
const folder = "Fluss-Monster.S01E02.Auf.der.Suche.nach.dem.Killer-Wels.German.DOKU.SATRiP.XviD-SAFARi";
|
|
||||||
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s01e02.avi", "safari-fm-s01e02", hash, hash);
|
|
||||||
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT fabricate a name from a token-LESS folder (Mega-Direct guard intact)", () => {
|
|
||||||
const decision = decideAutoRenameBaseName(["Mega-Direct-Pack", hash], "Direct.Show.S01E01.DIRECT.mkv", "Direct.Show.S01E01.DIRECT", hash, hash);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
|
|
||||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
|
||||||
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
|
|
||||||
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
|
|
||||||
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
|
|
||||||
|
|
||||||
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
|
|
||||||
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
|
|
||||||
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
|
|
||||||
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
|
|
||||||
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
|
|
||||||
expect(decision.kind).toBe("rename");
|
|
||||||
expect(extractEpisodeToken((decision as any).baseName)).toBe("S01E05");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT affect a folder that already carries an SxxExx token (safari S04E08a stays a rename)", () => {
|
|
||||||
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
|
||||||
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
|
||||||
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
|
|
||||||
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
|
|
||||||
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
|
|
||||||
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
|
|
||||||
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], cleanSource + ".mkv", cleanSource, epFolder, seasonFolder);
|
|
||||||
expect(decision.kind).toBe("skip");
|
|
||||||
expect(JSON.stringify(decision)).not.toContain("GROUP.S01E01");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
|
||||||
|
|
||||||
describe("backup-crypto", () => {
|
|
||||||
it("encrypts and decrypts a round-trip correctly", () => {
|
|
||||||
const original = JSON.stringify({
|
|
||||||
version: 2,
|
|
||||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
|
||||||
session: { packages: {}, items: {} },
|
|
||||||
history: [{ id: "h1", name: "Test" }]
|
|
||||||
});
|
|
||||||
|
|
||||||
const encrypted = encryptBackup(original);
|
|
||||||
const decrypted = decryptBackup(encrypted);
|
|
||||||
expect(decrypted).toBe(original);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces binary output that is not plaintext readable", () => {
|
|
||||||
const secret = "super-secret-token-12345";
|
|
||||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
|
||||||
const encrypted = encryptBackup(plaintext);
|
|
||||||
|
|
||||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
|
||||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts with the MDD1 magic bytes", () => {
|
|
||||||
const encrypted = encryptBackup("test");
|
|
||||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("produces different ciphertext for the same input (random IV)", () => {
|
|
||||||
const plaintext = "same input data";
|
|
||||||
const a = encryptBackup(plaintext);
|
|
||||||
const b = encryptBackup(plaintext);
|
|
||||||
expect(a.equals(b)).toBe(false);
|
|
||||||
expect(decryptBackup(a)).toBe(plaintext);
|
|
||||||
expect(decryptBackup(b)).toBe(plaintext);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on truncated data", () => {
|
|
||||||
const encrypted = encryptBackup("test data");
|
|
||||||
const truncated = encrypted.subarray(0, 10);
|
|
||||||
expect(() => decryptBackup(truncated)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on corrupted ciphertext", () => {
|
|
||||||
const encrypted = encryptBackup("test data");
|
|
||||||
const corrupted = Buffer.from(encrypted);
|
|
||||||
corrupted[corrupted.length - 1] ^= 0xff;
|
|
||||||
expect(() => decryptBackup(corrupted)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on wrong magic bytes", () => {
|
|
||||||
const encrypted = encryptBackup("test data");
|
|
||||||
const wrongMagic = Buffer.from(encrypted);
|
|
||||||
wrongMagic[0] = 0x00;
|
|
||||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws on empty buffer", () => {
|
|
||||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles large payloads", () => {
|
|
||||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
|
||||||
const encrypted = encryptBackup(large);
|
|
||||||
const decrypted = decryptBackup(encrypted);
|
|
||||||
expect(decrypted).toBe(large);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles unicode content", () => {
|
|
||||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
|
||||||
const encrypted = encryptBackup(unicode);
|
|
||||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles empty string round-trip", () => {
|
|
||||||
const encrypted = encryptBackup("");
|
|
||||||
expect(decryptBackup(encrypted)).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
|
|
||||||
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
|
|
||||||
|
|
||||||
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
|
|
||||||
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session: SessionState = {
|
|
||||||
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
|
|
||||||
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
|
|
||||||
reconnectReason: "", paused: false, running: true, updatedAt: 0
|
|
||||||
};
|
|
||||||
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
|
|
||||||
|
|
||||||
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
|
|
||||||
|
|
||||||
describe("buildBackupPayload — default is settings-only", () => {
|
|
||||||
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
|
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
|
||||||
expect(p.kind).toBe("settings-only");
|
|
||||||
expect(p.session).toBeUndefined();
|
|
||||||
expect(p.history).toBeUndefined();
|
|
||||||
expect(p.settings).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes session + history when backupIncludeDownloads is true", () => {
|
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
|
||||||
expect(p.kind).toBe("full");
|
|
||||||
expect(p.session).toBe(session);
|
|
||||||
expect(p.history).toBe(history);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a missing flag as settings-only (safe default)", () => {
|
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
|
|
||||||
expect(p.kind).toBe("settings-only");
|
|
||||||
expect(p.session).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
|
|
||||||
// "Haken aus bleibt aus": the exported settings object preserves the flag,
|
|
||||||
// so importing it keeps the toggle off.
|
|
||||||
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
|
||||||
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("planBackupImport — decision follows the file, not the local toggle", () => {
|
|
||||||
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
|
|
||||||
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
|
|
||||||
expect(plan.valid).toBe(true);
|
|
||||||
expect(plan.restoreDownloads).toBe(false);
|
|
||||||
expect(plan.message).toMatch(/Einstellungen/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("full backup (with session) -> restore downloads + relaunch", () => {
|
|
||||||
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
|
|
||||||
expect(plan.valid).toBe(true);
|
|
||||||
expect(plan.restoreDownloads).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects payloads without settings", () => {
|
|
||||||
expect(planBackupImport({ session }).valid).toBe(false);
|
|
||||||
expect(planBackupImport(null).valid).toBe(false);
|
|
||||||
expect(planBackupImport("nope").valid).toBe(false);
|
|
||||||
expect(planBackupImport({}).valid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a settings-only export then import does NOT pull in the download list", () => {
|
|
||||||
// Build with toggle off, then plan the import of exactly that payload.
|
|
||||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
|
|
||||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
|
||||||
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a full export then import DOES restore the download list", () => {
|
|
||||||
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
|
|
||||||
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
|
|
||||||
expect(plan.restoreDownloads).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -73,6 +73,7 @@ describe("bestdebrid-web", () => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(filePath, { force: true });
|
fs.rmSync(filePath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
// ignore temp cleanup failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,6 +42,7 @@ describe("cleanup", () => {
|
|||||||
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);
|
||||||
|
|
||||||
|
// Create nested directory structure with archive files
|
||||||
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 });
|
||||||
@ -50,15 +51,17 @@ describe("cleanup", () => {
|
|||||||
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");
|
||||||
|
// Non-archive files should be kept
|
||||||
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); // 2 rar parts + zip + 7z
|
||||||
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);
|
||||||
|
// Non-archives kept
|
||||||
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);
|
||||||
});
|
});
|
||||||
@ -67,17 +70,23 @@ describe("cleanup", () => {
|
|||||||
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);
|
||||||
|
|
||||||
|
// File with link-like name containing URLs should be removed
|
||||||
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");
|
||||||
|
// File with link-like name but no URLs should be kept
|
||||||
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");
|
||||||
|
// Regular text file that doesn't match the link pattern should be kept
|
||||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||||
|
// .url files should always be removed
|
||||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||||
|
// .dlc files should always be removed
|
||||||
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); // download_links.txt + bookmark.url + container.dlc
|
||||||
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);
|
||||||
|
// Non-matching files should be kept
|
||||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,9 @@ describe("container", () => {
|
|||||||
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));
|
||||||
|
|
||||||
|
// Create a valid mockup DLC that would be skipped if an error was thrown
|
||||||
const validFilePath = path.join(dir, "valid.dlc");
|
const validFilePath = path.join(dir, "valid.dlc");
|
||||||
|
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
|
||||||
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) => {
|
||||||
@ -36,6 +38,7 @@ describe("container", () => {
|
|||||||
|
|
||||||
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
||||||
|
|
||||||
|
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
|
||||||
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"]);
|
||||||
@ -57,14 +60,17 @@ describe("container", () => {
|
|||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "fallback.dlc");
|
const filePath = path.join(dir, "fallback.dlc");
|
||||||
|
|
||||||
|
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
|
||||||
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")) {
|
||||||
|
// Mock local RC service failure (returning 404)
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
|
// Mock dcrypt fallback success
|
||||||
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 });
|
||||||
@ -75,6 +81,7 @@ describe("container", () => {
|
|||||||
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"]);
|
||||||
|
// Should have tried both!
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,6 +135,7 @@ describe("container", () => {
|
|||||||
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"]);
|
||||||
|
// local RC + upload + paste = 3 calls
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1171
tests/debrid.test.ts
1171
tests/debrid.test.ts
File diff suppressed because it is too large
Load Diff
@ -1,538 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import http from "node:http";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { once } from "node:events";
|
|
||||||
import AdmZip from "adm-zip";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("../src/main/windows-host-diagnostics", () => ({
|
|
||||||
getWindowsHostDiagnostics: () => ({
|
|
||||||
collectedAt: "2026-03-09T00:00:03.000Z",
|
|
||||||
supported: true,
|
|
||||||
platform: "win32",
|
|
||||||
crashControl: {
|
|
||||||
crashDumpEnabled: 3,
|
|
||||||
minidumpDir: "C:\\Windows\\Minidumps",
|
|
||||||
dumpFile: "C:\\Windows\\MEMORY.DMP",
|
|
||||||
overwrite: 1,
|
|
||||||
logEvent: 1,
|
|
||||||
autoReboot: 1
|
|
||||||
},
|
|
||||||
recentKernelPower: [
|
|
||||||
{
|
|
||||||
timeCreated: "2026-03-09T00:00:04.000Z",
|
|
||||||
id: 41,
|
|
||||||
providerName: "Microsoft-Windows-Kernel-Power",
|
|
||||||
levelDisplayName: "Critical",
|
|
||||||
message: "unexpected restart",
|
|
||||||
bugcheckCode: "0",
|
|
||||||
bugcheckCodeHex: "",
|
|
||||||
reportId: ""
|
|
||||||
}
|
|
||||||
],
|
|
||||||
recentWerKernel: [],
|
|
||||||
recentKernelDump: [],
|
|
||||||
recentAppCrashes: [],
|
|
||||||
recentMinidumps: [],
|
|
||||||
assessmentHints: ["watchdog hint"],
|
|
||||||
errors: []
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { defaultSettings } from "../src/main/constants";
|
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
|
|
||||||
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
|
|
||||||
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
|
|
||||||
import { configureLogger, getLogFilePath, logger } from "../src/main/logger";
|
|
||||||
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
|
|
||||||
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
|
|
||||||
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
|
|
||||||
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
|
|
||||||
import { getTraceConfigPath, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "../src/main/trace-log";
|
|
||||||
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
|
|
||||||
import type { DownloadManager } from "../src/main/download-manager";
|
|
||||||
import type { UiSnapshot } from "../src/shared/types";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
|
||||||
const probe = http.createServer();
|
|
||||||
probe.listen(0, "127.0.0.1");
|
|
||||||
await once(probe, "listening");
|
|
||||||
const address = probe.address();
|
|
||||||
if (!address || typeof address === "string") {
|
|
||||||
throw new Error("port probe failed");
|
|
||||||
}
|
|
||||||
probe.close();
|
|
||||||
await once(probe, "close");
|
|
||||||
return address.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForReady(url: string): Promise<void> {
|
|
||||||
const deadline = Date.now() + 5000;
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
throw new Error(`debug server not ready: ${url}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSnapshot(baseDir: string): UiSnapshot {
|
|
||||||
const settings = {
|
|
||||||
...defaultSettings(),
|
|
||||||
outputDir: path.join(baseDir, "downloads"),
|
|
||||||
extractDir: path.join(baseDir, "extract")
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings,
|
|
||||||
session: {
|
|
||||||
version: 1,
|
|
||||||
packageOrder: ["pkg-1"],
|
|
||||||
packages: {
|
|
||||||
"pkg-1": {
|
|
||||||
id: "pkg-1",
|
|
||||||
name: "server-package",
|
|
||||||
outputDir: path.join(baseDir, "downloads", "server-package"),
|
|
||||||
extractDir: path.join(baseDir, "extract", "server-package"),
|
|
||||||
status: "downloading",
|
|
||||||
itemIds: ["item-1", "item-2"],
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
postProcessLabel: "",
|
|
||||||
createdAt: Date.now() - 30_000,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
"item-1": {
|
|
||||||
id: "item-1",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
url: "https://hoster.example/file-1",
|
|
||||||
provider: "realdebrid",
|
|
||||||
providerLabel: "Real-Debrid",
|
|
||||||
status: "downloading",
|
|
||||||
retries: 1,
|
|
||||||
speedBps: 8 * 1024 * 1024,
|
|
||||||
downloadedBytes: 64 * 1024 * 1024,
|
|
||||||
totalBytes: 256 * 1024 * 1024,
|
|
||||||
progressPercent: 25,
|
|
||||||
fileName: "episode.part1.rar",
|
|
||||||
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part1.rar"),
|
|
||||||
resumable: true,
|
|
||||||
attempts: 1,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "Download läuft (Real-Debrid)",
|
|
||||||
createdAt: Date.now() - 30_000,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
},
|
|
||||||
"item-2": {
|
|
||||||
id: "item-2",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
url: "https://hoster.example/file-2",
|
|
||||||
provider: "realdebrid",
|
|
||||||
providerLabel: "Real-Debrid",
|
|
||||||
status: "failed",
|
|
||||||
retries: 3,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
totalBytes: null,
|
|
||||||
progressPercent: 0,
|
|
||||||
fileName: "episode.part2.rar",
|
|
||||||
targetPath: path.join(baseDir, "downloads", "server-package", "episode.part2.rar"),
|
|
||||||
resumable: false,
|
|
||||||
attempts: 3,
|
|
||||||
lastError: "hoster unavailable",
|
|
||||||
fullStatus: "Fehler: hoster unavailable",
|
|
||||||
createdAt: Date.now() - 30_000,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
runStartedAt: Date.now() - 30_000,
|
|
||||||
totalDownloadedBytes: 64 * 1024 * 1024,
|
|
||||||
summaryText: "",
|
|
||||||
reconnectUntil: 0,
|
|
||||||
reconnectReason: "",
|
|
||||||
paused: false,
|
|
||||||
running: true,
|
|
||||||
updatedAt: Date.now()
|
|
||||||
},
|
|
||||||
summary: null,
|
|
||||||
stats: {
|
|
||||||
totalDownloaded: 64 * 1024 * 1024,
|
|
||||||
totalDownloadedAllTime: 128 * 1024 * 1024,
|
|
||||||
totalFilesSession: 0,
|
|
||||||
totalFilesAllTime: 0,
|
|
||||||
totalPackages: 1,
|
|
||||||
sessionStartedAt: Date.now() - 30_000,
|
|
||||||
appSessionStartedAt: Date.now() - 60_000,
|
|
||||||
sessionRuntimeMs: 60_000,
|
|
||||||
totalRuntimeMs: 3 * 60_000,
|
|
||||||
runtimeMeasuredAt: Date.now()
|
|
||||||
},
|
|
||||||
speedText: "8.0 MB/s",
|
|
||||||
etaText: "ETA: 00:25",
|
|
||||||
canStart: false,
|
|
||||||
canStop: true,
|
|
||||||
canPause: true,
|
|
||||||
clipboardActive: false,
|
|
||||||
reconnectSeconds: 0,
|
|
||||||
packageSpeedBps: {
|
|
||||||
"pkg-1": 8 * 1024 * 1024
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createFixture() {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-debug-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
const token = "debug-secret";
|
|
||||||
const port = await getFreePort();
|
|
||||||
const snapshot = buildSnapshot(baseDir);
|
|
||||||
const storagePaths = createStoragePaths(baseDir);
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
|
|
||||||
const debridLinkApiKeys = "key-a\nkey-b";
|
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
|
|
||||||
|
|
||||||
saveSettings(storagePaths, {
|
|
||||||
...snapshot.settings,
|
|
||||||
token: "rd-secret-token",
|
|
||||||
realDebridUseWebLogin: true,
|
|
||||||
debridLinkApiKeys,
|
|
||||||
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
|
|
||||||
totalDownloadedAllTime: 128 * 1024 * 1024,
|
|
||||||
totalCompletedFilesAllTime: 12,
|
|
||||||
totalRuntimeAllTimeMs: 5 * 60_000
|
|
||||||
});
|
|
||||||
saveHistory(storagePaths, [
|
|
||||||
{
|
|
||||||
id: "hist-1",
|
|
||||||
name: "server-package",
|
|
||||||
totalBytes: 123,
|
|
||||||
downloadedBytes: 123,
|
|
||||||
fileCount: 2,
|
|
||||||
provider: "realdebrid",
|
|
||||||
completedAt: Date.now() - 5_000,
|
|
||||||
durationSeconds: 42,
|
|
||||||
status: "completed",
|
|
||||||
outputDir: path.join(baseDir, "downloads", "server-package"),
|
|
||||||
urls: ["https://hoster.example/file-1"]
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
configureLogger(baseDir);
|
|
||||||
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
|
|
||||||
initAuditLog(baseDir);
|
|
||||||
const auditLogPath = getAuditLogPath();
|
|
||||||
if (!auditLogPath) {
|
|
||||||
throw new Error("audit log path missing");
|
|
||||||
}
|
|
||||||
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
|
|
||||||
|
|
||||||
initRenameLog(baseDir);
|
|
||||||
logRenameEvent("INFO", "RENAME-LINE", { stage: "auto-rename", sourcePath: "C:\\extract\\old.mkv" });
|
|
||||||
|
|
||||||
initTraceLog(baseDir);
|
|
||||||
setTraceEnabled(true, "test-fixture");
|
|
||||||
logTraceEvent("INFO", "support", "TRACE-EVENT", { scope: "fixture" });
|
|
||||||
|
|
||||||
initSessionLog(baseDir);
|
|
||||||
const sessionLogPath = getSessionLogPath();
|
|
||||||
if (!sessionLogPath) {
|
|
||||||
throw new Error("session log path missing");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(sessionLogPath, "2026-03-09T00:00:01.000Z [INFO] SESSION-LINE\n", "utf8");
|
|
||||||
logger.info("TRACE-MAIN-LINE");
|
|
||||||
|
|
||||||
initPackageLogs(baseDir);
|
|
||||||
initItemLogs(baseDir);
|
|
||||||
const packageLogPath = ensurePackageLog({
|
|
||||||
packageId: "pkg-1",
|
|
||||||
name: "server-package",
|
|
||||||
outputDir: snapshot.session.packages["pkg-1"]!.outputDir,
|
|
||||||
extractDir: snapshot.session.packages["pkg-1"]!.extractDir
|
|
||||||
});
|
|
||||||
if (!packageLogPath) {
|
|
||||||
throw new Error("package log path missing");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(packageLogPath, "2026-03-09T00:00:02.000Z [INFO] PACKAGE-LINE\n", "utf8");
|
|
||||||
const itemLogPath = ensureItemLog({
|
|
||||||
itemId: "item-2",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
packageName: "server-package",
|
|
||||||
fileName: "episode.part2.rar",
|
|
||||||
targetPath: snapshot.session.items["item-2"]!.targetPath
|
|
||||||
});
|
|
||||||
if (!itemLogPath) {
|
|
||||||
throw new Error("item log path missing");
|
|
||||||
}
|
|
||||||
fs.appendFileSync(itemLogPath, "2026-03-09T00:00:03.000Z [ERROR] ITEM-LINE\n", "utf8");
|
|
||||||
|
|
||||||
const manager = {
|
|
||||||
getSnapshot: () => snapshot,
|
|
||||||
getPackageLogPath: (packageId: string) => packageId === "pkg-1" ? packageLogPath : null,
|
|
||||||
getItemLogPath: (itemId: string) => itemId === "item-2" ? itemLogPath : null
|
|
||||||
} as unknown as DownloadManager;
|
|
||||||
|
|
||||||
startDebugServer(manager, baseDir);
|
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
|
||||||
await waitForReady(`${baseUrl}/health?token=${token}`);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
||||||
|
|
||||||
return {
|
|
||||||
baseUrl,
|
|
||||||
token,
|
|
||||||
baseDir
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
stopDebugServer();
|
|
||||||
shutdownSessionLog();
|
|
||||||
shutdownPackageLogs();
|
|
||||||
shutdownItemLogs();
|
|
||||||
shutdownRenameLog();
|
|
||||||
shutdownTraceLog();
|
|
||||||
shutdownAuditLog();
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
const dir = tempDirs.pop();
|
|
||||||
if (!dir) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("debug-server", () => {
|
|
||||||
it("serves diagnostics with main, session, and package log tails", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const payload = await response.json() as Record<string, any>;
|
|
||||||
|
|
||||||
expect(payload.meta?.appVersion).toBeTruthy();
|
|
||||||
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
|
|
||||||
expect(payload.status?.running).toBe(true);
|
|
||||||
expect(payload.host?.platform).toBe("win32");
|
|
||||||
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
|
||||||
expect(payload.selectedPackage?.name).toBe("server-package");
|
|
||||||
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
|
|
||||||
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
|
|
||||||
expect((payload.logs?.rename?.lines || []).join("\n")).toContain("RENAME-LINE");
|
|
||||||
expect((payload.logs?.trace?.lines || []).join("\n")).toContain("TRACE-EVENT");
|
|
||||||
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
|
|
||||||
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
|
||||||
expect(payload.accounts?.realDebrid?.configured).toBe(true);
|
|
||||||
expect(payload.history?.total).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes a machine-readable AI support manifest into the runtime folder", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json");
|
|
||||||
expect(fs.existsSync(manifestPath)).toBe(true);
|
|
||||||
|
|
||||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
|
|
||||||
expect(manifest.appVersion).toBeTruthy();
|
|
||||||
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
|
||||||
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
|
|
||||||
expect(manifest.quickstart?.[1]).toContain("server IP");
|
|
||||||
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
|
||||||
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
|
||||||
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
|
|
||||||
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
|
|
||||||
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
|
||||||
|
|
||||||
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
|
|
||||||
expect(metaResponse.ok).toBe(true);
|
|
||||||
const metaPayload = await metaResponse.json() as Record<string, any>;
|
|
||||||
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath);
|
|
||||||
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
|
|
||||||
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
|
|
||||||
expect(metaPayload.logPaths?.rename).toBe(getRenameLogPath());
|
|
||||||
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
|
|
||||||
expect(metaPayload.supportChecks?.selfCheck).toBe("/self-check");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves a debug setup check with trace expiry details", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const payload = await response.json() as Record<string, any>;
|
|
||||||
|
|
||||||
expect(payload.enabled).toBe(true);
|
|
||||||
expect(payload.status).toBe("ok");
|
|
||||||
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
|
||||||
expect(payload.host).toBe("0.0.0.0");
|
|
||||||
expect(payload.localOnly).toBe(false);
|
|
||||||
expect(payload.tokenConfigured).toBe(true);
|
|
||||||
expect(payload.aiManifestPresent).toBe(true);
|
|
||||||
expect(payload.traceEnabled).toBe(true);
|
|
||||||
expect(payload.traceAutoDisableAt).toBeTruthy();
|
|
||||||
expect(payload.diskSpace?.runtime?.freeBytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.diskSpace?.output?.freeBytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.diskSpace?.extract?.freeBytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.logSummary?.totalBytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.logSummary?.rename?.bytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
|
|
||||||
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
|
|
||||||
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
|
|
||||||
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
|
|
||||||
expect(Array.isArray(payload.notes)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves the self-check alias", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const payload = await response.json() as Record<string, any>;
|
|
||||||
expect(payload.status).toBe("ok");
|
|
||||||
expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the client IP into the debug trace log", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
|
|
||||||
headers: {
|
|
||||||
"X-Forwarded-For": "159.195.63.46"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
const traceLogPath = getTraceLogPath();
|
|
||||||
expect(traceLogPath).toBeTruthy();
|
|
||||||
const traceText = fs.readFileSync(traceLogPath!, "utf8");
|
|
||||||
expect(traceText).toContain("clientIp=159.195.63.46");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves package details and package log by package query", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
|
|
||||||
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
|
|
||||||
expect(packagesResponse.ok).toBe(true);
|
|
||||||
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
|
||||||
expect(packagesPayload.count).toBe(1);
|
|
||||||
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
|
||||||
|
|
||||||
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
|
|
||||||
expect(logResponse.ok).toBe(true);
|
|
||||||
const logPayload = await logResponse.json() as Record<string, any>;
|
|
||||||
expect(logPayload.package?.name).toBe("server-package");
|
|
||||||
expect((logPayload.lines || []).join("\n")).toContain("PACKAGE-LINE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves item log by item query", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const payload = await response.json() as Record<string, any>;
|
|
||||||
expect(payload.item?.id).toBe("item-2");
|
|
||||||
expect(payload.item?.fileName).toBe("episode.part2.rar");
|
|
||||||
expect((payload.lines || []).join("\n")).toContain("ITEM-LINE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves host diagnostics separately", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
const payload = await response.json() as Record<string, any>;
|
|
||||||
expect(payload.platform).toBe("win32");
|
|
||||||
expect(payload.crashControl?.crashDumpEnabled).toBe(3);
|
|
||||||
expect(payload.assessmentHints?.[0]).toContain("watchdog");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves audit log, settings, accounts, stats, and history", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
|
|
||||||
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
|
|
||||||
expect(auditResponse.ok).toBe(true);
|
|
||||||
const auditPayload = await auditResponse.json() as Record<string, any>;
|
|
||||||
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
|
|
||||||
|
|
||||||
const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`);
|
|
||||||
expect(renameResponse.ok).toBe(true);
|
|
||||||
const renamePayload = await renameResponse.json() as Record<string, any>;
|
|
||||||
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
|
|
||||||
|
|
||||||
const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`);
|
|
||||||
expect(traceResponse.ok).toBe(true);
|
|
||||||
const tracePayload = await traceResponse.json() as Record<string, any>;
|
|
||||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
|
|
||||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
|
|
||||||
|
|
||||||
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`);
|
|
||||||
expect(traceConfigResponse.ok).toBe(true);
|
|
||||||
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
|
|
||||||
expect(traceConfigPayload.config?.enabled).toBe(false);
|
|
||||||
|
|
||||||
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
|
|
||||||
expect(settingsResponse.ok).toBe(true);
|
|
||||||
const settingsPayload = await settingsResponse.json() as Record<string, any>;
|
|
||||||
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
|
|
||||||
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
|
|
||||||
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
|
|
||||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
|
|
||||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
|
|
||||||
|
|
||||||
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
|
|
||||||
expect(accountsResponse.ok).toBe(true);
|
|
||||||
const accountsPayload = await accountsResponse.json() as Record<string, any>;
|
|
||||||
expect(accountsPayload.debridLink?.keyCount).toBe(2);
|
|
||||||
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
|
|
||||||
|
|
||||||
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
|
|
||||||
expect(statsResponse.ok).toBe(true);
|
|
||||||
const statsPayload = await statsResponse.json() as Record<string, any>;
|
|
||||||
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
|
|
||||||
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
|
|
||||||
expect(historyResponse.ok).toBe(true);
|
|
||||||
const historyPayload = await historyResponse.json() as Record<string, any>;
|
|
||||||
expect(historyPayload.total).toBe(1);
|
|
||||||
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
|
|
||||||
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads a support bundle zip", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
|
|
||||||
expect(response.ok).toBe(true);
|
|
||||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
|
||||||
|
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
|
||||||
const zip = new AdmZip(buffer);
|
|
||||||
const entries = zip.getEntries().map((entry) => entry.entryName);
|
|
||||||
expect(entries).toContain("overview/settings.json");
|
|
||||||
expect(entries).toContain("overview/accounts.json");
|
|
||||||
expect(entries).toContain("overview/debug-setup.json");
|
|
||||||
expect(entries).toContain("overview/self-check.json");
|
|
||||||
expect(entries).toContain("overview/trace-config.json");
|
|
||||||
expect(entries).toContain("logs/audit.log");
|
|
||||||
expect(entries).toContain("logs/rename.log");
|
|
||||||
expect(entries).toContain("logs/trace.log");
|
|
||||||
expect(entries).toContain("runtime/debug_ai_manifest.json");
|
|
||||||
expect(entries).not.toContain("runtime/debug_token.txt");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unauthenticated requests", async () => {
|
|
||||||
const fixture = await createFixture();
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/status`);
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,124 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import {
|
|
||||||
getDesktopRenameLogPath,
|
|
||||||
initDesktopRenameLog,
|
|
||||||
logDesktopRename,
|
|
||||||
shutdownDesktopRenameLog,
|
|
||||||
verifyRename
|
|
||||||
} from "../src/main/desktop-rename-log";
|
|
||||||
|
|
||||||
const createdTmpDirs: string[] = [];
|
|
||||||
|
|
||||||
function tmpDesktop(): string {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rename-log-"));
|
|
||||||
createdTmpDirs.push(dir);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownDesktopRenameLog();
|
|
||||||
for (const dir of createdTmpDirs) {
|
|
||||||
try {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createdTmpDirs.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("desktop-rename-log", () => {
|
|
||||||
it("creates the Downloader-Log folder + session file on init and appends formatted lines", () => {
|
|
||||||
const desktop = tmpDesktop();
|
|
||||||
initDesktopRenameLog(desktop);
|
|
||||||
|
|
||||||
const logPath = getDesktopRenameLogPath();
|
|
||||||
expect(logPath).toBeTruthy();
|
|
||||||
expect(path.dirname(logPath as string).endsWith("Downloader-Log")).toBe(true);
|
|
||||||
expect(fs.existsSync(logPath as string)).toBe(true);
|
|
||||||
|
|
||||||
logDesktopRename("INFO", "Test-Rename", { source: "a.mkv", requested: "b.mkv" });
|
|
||||||
const content = fs.readFileSync(logPath as string, "utf8");
|
|
||||||
expect(content).toContain("Rename-Session gestartet");
|
|
||||||
expect(content).toContain("Test-Rename");
|
|
||||||
expect(content).toContain("source=a.mkv");
|
|
||||||
expect(content).toContain("requested=b.mkv");
|
|
||||||
expect(content).toMatch(/\[INFO\]/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
|
|
||||||
const desktop = tmpDesktop();
|
|
||||||
initDesktopRenameLog(desktop);
|
|
||||||
const logPath = getDesktopRenameLogPath() as string;
|
|
||||||
logDesktopRename("INFO", "ZeileA");
|
|
||||||
|
|
||||||
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
|
||||||
expect(fs.existsSync(logPath)).toBe(false);
|
|
||||||
|
|
||||||
logDesktopRename("INFO", "ZeileB");
|
|
||||||
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
|
||||||
expect(fs.existsSync(logPath)).toBe(true);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(logPath, "utf8");
|
|
||||||
expect(content).toContain("Rename-Session gestartet");
|
|
||||||
expect(content).toContain("ZeileB");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is a silent no-op when initialized without a desktop path (never throws)", () => {
|
|
||||||
initDesktopRenameLog("");
|
|
||||||
expect(getDesktopRenameLogPath()).toBeNull();
|
|
||||||
expect(() => logDesktopRename("INFO", "egal")).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("verifyRename: ok when the target exists under the exact name and the source is gone", () => {
|
|
||||||
const dir = tmpDesktop();
|
|
||||||
const source = path.join(dir, "scn-xyz.part1.rar");
|
|
||||||
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
|
||||||
fs.writeFileSync(target, "data");
|
|
||||||
|
|
||||||
const v = verifyRename(source, target);
|
|
||||||
expect(v.ok).toBe(true);
|
|
||||||
expect(v.level).toBe("INFO");
|
|
||||||
expect(v.targetExists).toBe(true);
|
|
||||||
expect(v.onDiskName).toBe("Movie.2024.German.1080p.part1.rar");
|
|
||||||
expect(v.nameMatches).toBe(true);
|
|
||||||
expect(v.sourceGone).toBe(true);
|
|
||||||
expect(v.targetSize).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("verifyRename: FAILS when the target is missing although rename reported success", () => {
|
|
||||||
const dir = tmpDesktop();
|
|
||||||
const v = verifyRename(path.join(dir, "src.rar"), path.join(dir, "never-created.rar"));
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.level).toBe("ERROR");
|
|
||||||
expect(v.targetExists).toBe(false);
|
|
||||||
expect(v.reason).toMatch(/nicht gefunden/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
|
|
||||||
const dir = tmpDesktop();
|
|
||||||
const source = path.join(dir, "src.rar");
|
|
||||||
const target = path.join(dir, "dst.rar");
|
|
||||||
fs.writeFileSync(source, "x");
|
|
||||||
fs.writeFileSync(target, "x");
|
|
||||||
|
|
||||||
const v = verifyRename(source, target);
|
|
||||||
expect(v.ok).toBe(false);
|
|
||||||
expect(v.level).toBe("ERROR");
|
|
||||||
expect(v.sourceGone).toBe(false);
|
|
||||||
expect(v.reason).toMatch(/Quelldatei existiert noch/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("verifyRename: an in-place rename (same path) is ok and does not flag a lingering source", () => {
|
|
||||||
const dir = tmpDesktop();
|
|
||||||
const p = path.join(dir, "file.mkv");
|
|
||||||
fs.writeFileSync(p, "x");
|
|
||||||
|
|
||||||
const v = verifyRename(p, p);
|
|
||||||
expect(v.ok).toBe(true);
|
|
||||||
expect(v.targetExists).toBe(true);
|
|
||||||
expect(v.nameMatches).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
|
||||||
|
|
||||||
describe("download-completion", () => {
|
|
||||||
describe("planDownloadCompletion", () => {
|
|
||||||
it("uses content-length when present", () => {
|
|
||||||
const plan = planDownloadCompletion({
|
|
||||||
existingBytes: 0, responseStatus: 200, contentLength: 1000,
|
|
||||||
totalFromRange: null, knownTotal: null, correctedTotal: null
|
|
||||||
});
|
|
||||||
expect(plan.source).toBe("content-length");
|
|
||||||
expect(plan.expectedTotal).toBe(1000);
|
|
||||||
});
|
|
||||||
it("falls back to stream-end when no size info is available", () => {
|
|
||||||
const plan = planDownloadCompletion({
|
|
||||||
existingBytes: 0, responseStatus: 200, contentLength: 0,
|
|
||||||
totalFromRange: null, knownTotal: null, correctedTotal: null
|
|
||||||
});
|
|
||||||
expect(plan.source).toBe("stream-end");
|
|
||||||
expect(plan.expectedTotal).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("validateDownloadedFileCompletion", () => {
|
|
||||||
const streamEnd = { expectedTotal: null, source: "stream-end" as const, canFinishEarly: false };
|
|
||||||
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
|
||||||
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
|
||||||
|
|
||||||
it("rejects a 0-byte stream-end download (H3)", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.error).toContain("download_underflow");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a non-empty stream-end download", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 5_000_000, plan: streamEnd });
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
expect(result.totalBytes).toBe(5_000_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an underflowing content-length download", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 400, plan: contentLength(1000), toleranceBytes: 0 });
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts a complete content-length download", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 1000, plan: contentLength(1000) });
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a 0-byte download even with known provider size", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: providerMeta(2000) });
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts provider-metadata download and flags size mismatch", () => {
|
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,44 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createErrorRing } from "../src/main/error-ring";
|
|
||||||
|
|
||||||
describe("createErrorRing", () => {
|
|
||||||
it("keeps entries in insertion order", () => {
|
|
||||||
const ring = createErrorRing(10);
|
|
||||||
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
|
||||||
ring.push({ ts: "t2", level: "WARN", message: "b" });
|
|
||||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
|
|
||||||
expect(ring.size()).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps at capacity by dropping the oldest", () => {
|
|
||||||
const ring = createErrorRing(3);
|
|
||||||
for (const m of ["a", "b", "c", "d", "e"]) {
|
|
||||||
ring.push({ ts: m, level: "ERROR", message: m });
|
|
||||||
}
|
|
||||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
|
|
||||||
expect(ring.size()).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("snapshot returns a copy, not the live buffer", () => {
|
|
||||||
const ring = createErrorRing(5);
|
|
||||||
ring.push({ ts: "t", level: "WARN", message: "x" });
|
|
||||||
const snap = ring.snapshot();
|
|
||||||
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
|
|
||||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clear empties the ring", () => {
|
|
||||||
const ring = createErrorRing(5);
|
|
||||||
ring.push({ ts: "t", level: "ERROR", message: "x" });
|
|
||||||
ring.clear();
|
|
||||||
expect(ring.snapshot()).toEqual([]);
|
|
||||||
expect(ring.size()).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coerces a non-positive capacity to at least 1", () => {
|
|
||||||
const ring = createErrorRing(0);
|
|
||||||
ring.push({ ts: "t1", level: "ERROR", message: "a" });
|
|
||||||
ring.push({ ts: "t2", level: "ERROR", message: "b" });
|
|
||||||
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -74,6 +74,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create a ZIP with some content to trigger progress
|
||||||
const zipPath = path.join(packageDir, "progress-test.zip");
|
const zipPath = path.join(packageDir, "progress-test.zip");
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
||||||
@ -107,16 +108,20 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
expect(result.extracted).toBe(1);
|
expect(result.extracted).toBe(1);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
|
|
||||||
|
// Should have at least preparing, extracting, and done phases
|
||||||
const phases = new Set(progressUpdates.map((u) => u.phase));
|
const phases = new Set(progressUpdates.map((u) => u.phase));
|
||||||
expect(phases.has("preparing")).toBe(true);
|
expect(phases.has("preparing")).toBe(true);
|
||||||
expect(phases.has("extracting")).toBe(true);
|
expect(phases.has("extracting")).toBe(true);
|
||||||
|
|
||||||
|
// Extracting phase should include the archive name
|
||||||
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
||||||
expect(extracting.length).toBeGreaterThan(0);
|
expect(extracting.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should end at 100%
|
||||||
const lastExtracting = extracting[extracting.length - 1];
|
const lastExtracting = extracting[extracting.length - 1];
|
||||||
expect(lastExtracting.archivePercent).toBe(100);
|
expect(lastExtracting.archivePercent).toBe(100);
|
||||||
|
|
||||||
|
// Files should exist
|
||||||
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
@ -130,6 +135,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create two separate ZIP archives
|
||||||
const zip1 = new AdmZip();
|
const zip1 = new AdmZip();
|
||||||
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
||||||
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
||||||
@ -156,8 +162,10 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
|
|
||||||
expect(result.extracted).toBe(2);
|
expect(result.extracted).toBe(2);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
|
// Both archive names should have appeared in progress
|
||||||
expect(archiveNames.has("archive1.zip")).toBe(true);
|
expect(archiveNames.has("archive1.zip")).toBe(true);
|
||||||
expect(archiveNames.has("archive2.zip")).toBe(true);
|
expect(archiveNames.has("archive2.zip")).toBe(true);
|
||||||
|
// Both files extracted
|
||||||
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,19 +5,12 @@ import AdmZip from "adm-zip";
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildExternalExtractArgs,
|
buildExternalExtractArgs,
|
||||||
cleanErrorText,
|
|
||||||
collectArchiveCleanupTargets,
|
collectArchiveCleanupTargets,
|
||||||
extractPackageArchives,
|
extractPackageArchives,
|
||||||
type ExtractArchiveFailureInfo,
|
|
||||||
archiveFilenamePasswords,
|
archiveFilenamePasswords,
|
||||||
detectArchiveSignature,
|
detectArchiveSignature,
|
||||||
classifyExtractionError,
|
classifyExtractionError,
|
||||||
shouldSerialRetryParallelFailures,
|
|
||||||
findArchiveCandidates,
|
findArchiveCandidates,
|
||||||
orderExtractorCandidatesForArchive,
|
|
||||||
resolveExtractorBackendModeForArchive,
|
|
||||||
resolveExtractorBackendMode,
|
|
||||||
shouldFallbackLegacyRarToJvm,
|
|
||||||
} from "../src/main/extractor";
|
} from "../src/main/extractor";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
@ -72,11 +65,6 @@ describe("extractor", () => {
|
|||||||
expect(unrarRename[2]).toBe("-p-");
|
expect(unrarRename[2]).toBe("-p-");
|
||||||
expect(unrarRename[3]).toBe("-y");
|
expect(unrarRename[3]).toBe("-y");
|
||||||
expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar");
|
expect(unrarRename[unrarRename.length - 2]).toBe("archive.rar");
|
||||||
|
|
||||||
const rarCliArgs = buildExternalExtractArgs("Rar.exe", "archive.rar", "C:\\target", "overwrite", "serienjunkies.org");
|
|
||||||
expect(rarCliArgs.slice(0, 4)).toEqual(["x", "-o+", "-pserienjunkies.org", "-y"]);
|
|
||||||
expect(rarCliArgs[rarCliArgs.length - 2]).toBe("archive.rar");
|
|
||||||
expect(rarCliArgs[rarCliArgs.length - 1]).toBe("C:\\target\\");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("deletes only successfully extracted archives", async () => {
|
it("deletes only successfully extracted archives", async () => {
|
||||||
@ -865,6 +853,7 @@ describe("extractor", () => {
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const filePath = path.join(root, "test.rar");
|
const filePath = path.join(root, "test.rar");
|
||||||
|
// RAR5 signature: 52 61 72 21 1A 07
|
||||||
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
|
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
|
||||||
const sig = await detectArchiveSignature(filePath);
|
const sig = await detectArchiveSignature(filePath);
|
||||||
expect(sig).toBe("rar");
|
expect(sig).toBe("rar");
|
||||||
@ -941,6 +930,7 @@ describe("extractor", () => {
|
|||||||
const candidates = await findArchiveCandidates(packageDir);
|
const candidates = await findArchiveCandidates(packageDir);
|
||||||
const names = candidates.map((c) => path.basename(c));
|
const names = candidates.map((c) => path.basename(c));
|
||||||
expect(names).toContain("movie.001");
|
expect(names).toContain("movie.001");
|
||||||
|
// .002 should NOT be in candidates (only .001 is the entry point)
|
||||||
expect(names).not.toContain("movie.002");
|
expect(names).not.toContain("movie.002");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -955,41 +945,9 @@ describe("extractor", () => {
|
|||||||
|
|
||||||
const candidates = await findArchiveCandidates(packageDir);
|
const candidates = await findArchiveCandidates(packageDir);
|
||||||
const names = candidates.map((c) => path.basename(c));
|
const names = candidates.map((c) => path.basename(c));
|
||||||
|
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
|
||||||
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
|
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores duplicate-suffixed multipart rar volumes as standalone candidates", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rar-dup-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const packageDir = path.join(root, "pkg");
|
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1.rar"), "data", "utf8");
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2.rar"), "data", "utf8");
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1 (1).rar"), "data", "utf8");
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2 (1).rar"), "data", "utf8");
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part5 (1).rar"), "data", "utf8");
|
|
||||||
|
|
||||||
const candidates = await findArchiveCandidates(packageDir);
|
|
||||||
const names = candidates.map((c) => path.basename(c));
|
|
||||||
|
|
||||||
expect(names).toContain("Sanctuary720-01x07.part1.rar");
|
|
||||||
expect(names).not.toContain("Sanctuary720-01x07.part1 (1).rar");
|
|
||||||
expect(names).not.toContain("Sanctuary720-01x07.part2 (1).rar");
|
|
||||||
expect(names).not.toContain("Sanctuary720-01x07.part5 (1).rar");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps single rar files with duplicate suffix as valid candidates", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-single-rar-dup-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const packageDir = path.join(root, "pkg");
|
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(packageDir, "Movie (1).rar"), "data", "utf8");
|
|
||||||
|
|
||||||
const candidates = await findArchiveCandidates(packageDir);
|
|
||||||
expect(candidates.map((c) => path.basename(c))).toContain("Movie (1).rar");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifyExtractionError", () => {
|
describe("classifyExtractionError", () => {
|
||||||
@ -1030,66 +988,12 @@ describe("extractor", () => {
|
|||||||
expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor");
|
expect(classifyExtractionError("WinRAR/UnRAR nicht gefunden")).toBe("no_extractor");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prioritizes checksum errors over embedded wrong-password wording", () => {
|
|
||||||
expect(classifyExtractionError("Checksum error in the encrypted file. Corrupt file or wrong password.")).toBe("crc_error");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns unknown for unrecognized errors", () => {
|
it("returns unknown for unrecognized errors", () => {
|
||||||
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
expect(classifyExtractionError("something weird happened")).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps important tail markers when long extractor output is trimmed", () => {
|
|
||||||
const noisy = `Extracting from archive.rar ${"x".repeat(700)} Unexpected end of archive`;
|
|
||||||
const cleaned = cleanErrorText(noisy);
|
|
||||||
expect(cleaned).toContain("Unexpected end of archive");
|
|
||||||
expect(classifyExtractionError(cleaned)).toBe("missing_parts");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shouldSerialRetryParallelFailures", () => {
|
|
||||||
it("keeps serial recovery enabled after mixed parallel results", () => {
|
|
||||||
expect(shouldSerialRetryParallelFailures(1, ["wrong_password"])).toBe(true);
|
|
||||||
expect(shouldSerialRetryParallelFailures(2, ["missing_parts"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only retries a total parallel wipe-out for contention-like failures", () => {
|
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["crc_error", "wrong_password", "unknown"])).toBe(true);
|
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["missing_parts"])).toBe(false);
|
|
||||||
expect(shouldSerialRetryParallelFailures(0, ["unsupported_format", "crc_error"])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("password discovery", () => {
|
describe("password discovery", () => {
|
||||||
it("reports per-archive failures through onArchiveFailure", async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const packageDir = path.join(root, "pkg");
|
|
||||||
const targetDir = path.join(root, "out");
|
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
|
||||||
|
|
||||||
fs.writeFileSync(path.join(packageDir, "broken.zip"), "not-a-zip", "utf8");
|
|
||||||
const failures: ExtractArchiveFailureInfo[] = [];
|
|
||||||
|
|
||||||
const result = await extractPackageArchives({
|
|
||||||
packageDir,
|
|
||||||
targetDir,
|
|
||||||
cleanupMode: "none",
|
|
||||||
conflictMode: "overwrite",
|
|
||||||
removeLinks: false,
|
|
||||||
removeSamples: false,
|
|
||||||
onArchiveFailure: (failure) => {
|
|
||||||
failures.push(failure);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.extracted).toBe(0);
|
|
||||||
expect(result.failed).toBe(1);
|
|
||||||
expect(failures).toHaveLength(1);
|
|
||||||
expect(failures[0]?.archiveName).toBe("broken.zip");
|
|
||||||
expect(failures[0]?.category).toBe("unsupported_format");
|
|
||||||
expect(failures[0]?.suggestRedownload).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts first archive serially before parallel pool when multiple passwords", async () => {
|
it("extracts first archive serially before parallel pool when multiple passwords", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-pwdisc-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
@ -1097,6 +1001,7 @@ describe("extractor", () => {
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create 3 zip archives
|
||||||
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
|
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile(`${name}.txt`, Buffer.from(name));
|
zip.addFile(`${name}.txt`, Buffer.from(name));
|
||||||
@ -1123,6 +1028,7 @@ describe("extractor", () => {
|
|||||||
|
|
||||||
expect(result.extracted).toBe(3);
|
expect(result.extracted).toBe(3);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
|
// First archive should be ep01 (natural order, extracted serially for discovery)
|
||||||
expect(seenOrder[0]).toBe("ep01.zip");
|
expect(seenOrder[0]).toBe("ep01.zip");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1139,6 +1045,7 @@ describe("extractor", () => {
|
|||||||
zip.writeZip(path.join(packageDir, name));
|
zip.writeZip(path.join(packageDir, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No passwordList → only empty string → length=1 → no discovery phase
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir,
|
packageDir,
|
||||||
targetDir,
|
targetDir,
|
||||||
@ -1179,79 +1086,4 @@ describe("extractor", () => {
|
|||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backend selection", () => {
|
|
||||||
it("defaults to auto in production when no backend override is set", () => {
|
|
||||||
expect(resolveExtractorBackendMode(undefined, false)).toBe("auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to legacy in vitest when no backend override is set", () => {
|
|
||||||
expect(resolveExtractorBackendMode(undefined, true)).toBe("legacy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects explicit backend overrides", () => {
|
|
||||||
expect(resolveExtractorBackendMode("legacy", false)).toBe("legacy");
|
|
||||||
expect(resolveExtractorBackendMode("jvm", false)).toBe("jvm");
|
|
||||||
expect(resolveExtractorBackendMode("auto", false)).toBe("auto");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers legacy for rar archives in auto mode on Windows", () => {
|
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", undefined, false, "win32")).toBe("legacy");
|
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.r00", undefined, false, "win32")).toBe("legacy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back from legacy rar to jvm after partial-progress failure in auto mode on Windows", () => {
|
|
||||||
expect(
|
|
||||||
shouldFallbackLegacyRarToJvm(
|
|
||||||
"C:\\Downloads\\episode.part01.rar",
|
|
||||||
"auto",
|
|
||||||
"legacy",
|
|
||||||
"Error: Extracting from C:\\Downloads\\episode.part01.rar",
|
|
||||||
38,
|
|
||||||
"win32"
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips legacy rar to jvm fallback for explicit legacy mode and non-rar cases", () => {
|
|
||||||
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "legacy", "legacy", "checksum error", 38, "win32")).toBe(false);
|
|
||||||
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.zip", "auto", "legacy", "unknown failure", 38, "win32")).toBe(false);
|
|
||||||
expect(shouldFallbackLegacyRarToJvm("C:\\Downloads\\episode.part01.rar", "auto", "legacy", "timeout", 38, "win32")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps auto for non-rar archives and respects explicit overrides", () => {
|
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.zip", undefined, false, "win32")).toBe("auto");
|
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "jvm", false, "win32")).toBe("jvm");
|
|
||||||
expect(resolveExtractorBackendModeForArchive("C:\\Downloads\\episode.part01.rar", "legacy", false, "win32")).toBe("legacy");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("orderExtractorCandidatesForArchive", () => {
|
|
||||||
it("prefers RAR-native CLIs over 7-Zip for rar archives", () => {
|
|
||||||
const ordered = orderExtractorCandidatesForArchive(
|
|
||||||
["7z.exe", "Rar.exe", "UnRAR.exe", "WinRAR.exe"],
|
|
||||||
"C:\\Downloads\\archive.part01.rar"
|
|
||||||
);
|
|
||||||
expect(ordered.slice(0, 3)).toEqual(["Rar.exe", "UnRAR.exe", "WinRAR.exe"]);
|
|
||||||
expect(ordered[3]).toBe("7z.exe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps 7-Zip first for non-rar archives", () => {
|
|
||||||
const ordered = orderExtractorCandidatesForArchive(
|
|
||||||
["UnRAR.exe", "7z.exe", "WinRAR.exe"],
|
|
||||||
"C:\\Downloads\\archive.zip"
|
|
||||||
);
|
|
||||||
expect(ordered[0]).toBe("7z.exe");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers the remembered command within the matching archive class", () => {
|
|
||||||
const ordered = orderExtractorCandidatesForArchive(
|
|
||||||
["UnRAR.exe", "WinRAR.exe", "7z.exe"],
|
|
||||||
"C:\\Downloads\\archive.part01.rar",
|
|
||||||
"WinRAR.exe"
|
|
||||||
);
|
|
||||||
expect(ordered[0]).toBe("WinRAR.exe");
|
|
||||||
expect(ordered[1]).toBe("UnRAR.exe");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { classifyDiskError } from "../src/main/fs-error";
|
|
||||||
import { isDebugFlagEnabled } from "../src/main/logger";
|
|
||||||
|
|
||||||
describe("classifyDiskError", () => {
|
|
||||||
it("maps ENOSPC from an error code to a disk-full reason", () => {
|
|
||||||
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
|
|
||||||
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps EACCES from a code to a permission reason", () => {
|
|
||||||
const err = Object.assign(new Error("nope"), { code: "EACCES" });
|
|
||||||
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lower-case codes are normalized", () => {
|
|
||||||
const err = Object.assign(new Error("x"), { code: "enospc" });
|
|
||||||
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to scanning the message text when no code is present", () => {
|
|
||||||
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles a plain string error", () => {
|
|
||||||
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for an unrelated error", () => {
|
|
||||||
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
|
|
||||||
expect(classifyDiskError(new Error("premature close"))).toBeNull();
|
|
||||||
expect(classifyDiskError(null)).toBeNull();
|
|
||||||
expect(classifyDiskError(undefined)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isDebugFlagEnabled", () => {
|
|
||||||
it("is true for affirmative values", () => {
|
|
||||||
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
|
|
||||||
expect(isDebugFlagEnabled(v)).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for empty/negative/garbage values", () => {
|
|
||||||
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
|
|
||||||
expect(isDebugFlagEnabled(v)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,150 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
|
|
||||||
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
|
|
||||||
// download-manager's selection + .DL.-rename wiring is exercised for real.
|
|
||||||
vi.mock("../src/main/video-processor", async (importActual) => {
|
|
||||||
const actual = await importActual<typeof import("../src/main/video-processor")>();
|
|
||||||
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
|
|
||||||
});
|
|
||||||
|
|
||||||
import { DownloadManager } from "../src/main/download-manager";
|
|
||||||
import { defaultSettings } from "../src/main/constants";
|
|
||||||
import { createStoragePaths, emptySession } from "../src/main/storage";
|
|
||||||
import { shutdownItemLogs } from "../src/main/item-log";
|
|
||||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
|
||||||
import { shutdownRenameLog } from "../src/main/rename-log";
|
|
||||||
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
|
|
||||||
|
|
||||||
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
|
|
||||||
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockedProcess.mockReset();
|
|
||||||
mockedTooling.mockReset();
|
|
||||||
shutdownItemLogs();
|
|
||||||
shutdownPackageLogs();
|
|
||||||
shutdownRenameLog();
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
|
|
||||||
tempDirs.push(root);
|
|
||||||
const extractDir = path.join(root, "extract");
|
|
||||||
const stateDir = path.join(root, "state");
|
|
||||||
fs.mkdirSync(extractDir, { recursive: true });
|
|
||||||
fs.mkdirSync(stateDir, { recursive: true });
|
|
||||||
const manager = new DownloadManager(
|
|
||||||
{
|
|
||||||
...defaultSettings(),
|
|
||||||
token: "rd-token",
|
|
||||||
keepGermanAudioOnly,
|
|
||||||
germanAudioMode: "tag",
|
|
||||||
autoRename4sf4sj: false,
|
|
||||||
outputDir: path.join(root, "out"),
|
|
||||||
extractDir,
|
|
||||||
mkvLibraryDir: path.join(stateDir, "_mkv")
|
|
||||||
},
|
|
||||||
emptySession(),
|
|
||||||
createStoragePaths(stateDir)
|
|
||||||
);
|
|
||||||
const pkg: any = {
|
|
||||||
id: "ga-pkg-1",
|
|
||||||
name: "Test.Show.S01.GERMAN.DL.720p",
|
|
||||||
outputDir: path.join(root, "out", "Test.Show"),
|
|
||||||
extractDir,
|
|
||||||
status: "completed",
|
|
||||||
itemIds: [],
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
createdAt: 0,
|
|
||||||
updatedAt: 0
|
|
||||||
};
|
|
||||||
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
|
|
||||||
// processVideoFile. Tests that need the no-tool path override this.
|
|
||||||
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
|
|
||||||
return { extractDir, manager, pkg };
|
|
||||||
}
|
|
||||||
|
|
||||||
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
|
|
||||||
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
|
|
||||||
const SAMPLE_DL = "Show.sample.DL.mkv";
|
|
||||||
const DL_AVI = "Show.S01E03.German.DL.avi";
|
|
||||||
|
|
||||||
function stage(extractDir: string): void {
|
|
||||||
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
|
|
||||||
fs.writeFileSync(path.join(extractDir, f), "x");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("keepGermanAudioOnly integration", () => {
|
|
||||||
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
|
|
||||||
const { extractDir, manager, pkg } = setup(true);
|
|
||||||
stage(extractDir);
|
|
||||||
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
|
|
||||||
|
|
||||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
||||||
|
|
||||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
|
|
||||||
expect(n).toBe(1);
|
|
||||||
|
|
||||||
const files = fs.readdirSync(extractDir);
|
|
||||||
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
|
|
||||||
expect(files).not.toContain(DL_MKV);
|
|
||||||
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
|
|
||||||
expect(files).toContain(SAMPLE_DL); // sample skipped
|
|
||||||
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does nothing when the setting is off", async () => {
|
|
||||||
const { extractDir, manager, pkg } = setup(false);
|
|
||||||
stage(extractDir);
|
|
||||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
||||||
expect(n).toBe(0);
|
|
||||||
expect(mockedProcess).not.toHaveBeenCalled();
|
|
||||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the file fully untouched (name included) when no German track is found", async () => {
|
|
||||||
const { extractDir, manager, pkg } = setup(true);
|
|
||||||
stage(extractDir);
|
|
||||||
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
|
|
||||||
|
|
||||||
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
||||||
|
|
||||||
expect(mockedProcess).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
|
|
||||||
const { extractDir, manager, pkg } = setup(true);
|
|
||||||
stage(extractDir);
|
|
||||||
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
|
|
||||||
|
|
||||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
||||||
|
|
||||||
expect(n).toBe(0); // not counted as a remux
|
|
||||||
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
|
|
||||||
const { extractDir, manager, pkg } = setup(true);
|
|
||||||
stage(extractDir);
|
|
||||||
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
|
|
||||||
|
|
||||||
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
|
|
||||||
|
|
||||||
expect(n).toBe(0);
|
|
||||||
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
|
|
||||||
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -34,20 +34,25 @@ describe("integrity", () => {
|
|||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
|
// Create a .md5 manifest that exceeds the 5MB limit
|
||||||
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
||||||
const manifestPath = path.join(dir, "hashes.md5");
|
const manifestPath = path.join(dir, "hashes.md5");
|
||||||
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
||||||
|
|
||||||
|
// Verify the file is actually > 5MB
|
||||||
const stat = fs.statSync(manifestPath);
|
const stat = fs.statSync(manifestPath);
|
||||||
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
||||||
|
|
||||||
|
// readHashManifest should skip the oversized file
|
||||||
const manifest = readHashManifest(dir);
|
const manifest = readHashManifest(dir);
|
||||||
expect(manifest.size).toBe(0);
|
expect(manifest.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
||||||
|
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
|
||||||
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
||||||
const result = parseHashLine(sha256Line);
|
const result = parseHashLine(sha256Line);
|
||||||
|
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { ensureItemLog, getItemLogPath, initItemLogs, logItemEvent, shutdownItemLogs } from "../src/main/item-log";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownItemLogs();
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("item-log", () => {
|
|
||||||
it("creates a persistent item log file", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initItemLogs(baseDir);
|
|
||||||
const logPath = ensureItemLog({
|
|
||||||
itemId: "item-1",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
packageName: "Test Paket",
|
|
||||||
fileName: "episode.part2.rar",
|
|
||||||
targetPath: "C:\\downloads\\Test Paket\\episode.part2.rar"
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
expect(fs.existsSync(logPath!)).toBe(true);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Item-Log Start");
|
|
||||||
expect(content).toContain("episode.part2.rar");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes detail events into the item log", async () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initItemLogs(baseDir);
|
|
||||||
ensureItemLog({
|
|
||||||
itemId: "item-2",
|
|
||||||
packageId: "pkg-2",
|
|
||||||
packageName: "Detail Paket",
|
|
||||||
fileName: "episode.part2.rar",
|
|
||||||
targetPath: "C:\\downloads\\Detail Paket\\episode.part2.rar"
|
|
||||||
});
|
|
||||||
|
|
||||||
logItemEvent("item-2", "ERROR", "Entpack-Fehler", {
|
|
||||||
archive: "episode.part2.rar",
|
|
||||||
code: "missing_parts",
|
|
||||||
detail: "Unexpected end of archive"
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
||||||
|
|
||||||
const logPath = getItemLogPath("item-2");
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Entpack-Fehler");
|
|
||||||
expect(content).toContain("archive=episode.part2.rar");
|
|
||||||
expect(content).toContain("code=missing_parts");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps traversal-like item ids inside the item log directory", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ilog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initItemLogs(baseDir);
|
|
||||||
const logPath = ensureItemLog({
|
|
||||||
itemId: "..\\..\\outside",
|
|
||||||
packageId: "pkg-traversal",
|
|
||||||
packageName: "Traversal Paket",
|
|
||||||
fileName: "episode.part2.rar",
|
|
||||||
targetPath: "C:\\downloads\\Traversal Paket\\episode.part2.rar"
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
const logsDir = path.resolve(path.join(baseDir, "item-logs"));
|
|
||||||
const resolvedLogPath = path.resolve(logPath!);
|
|
||||||
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildLinkExportSelection, serializeLinkExportText } from "../src/main/link-export";
|
|
||||||
import { parseCollectorInput } from "../src/main/link-parser";
|
|
||||||
import type { UiSnapshot } from "../src/shared/types";
|
|
||||||
|
|
||||||
function buildSnapshot(): UiSnapshot {
|
|
||||||
return {
|
|
||||||
settings: {} as UiSnapshot["settings"],
|
|
||||||
session: {
|
|
||||||
version: 1,
|
|
||||||
packageOrder: ["pkg-1", "pkg-2"],
|
|
||||||
packages: {
|
|
||||||
"pkg-1": {
|
|
||||||
id: "pkg-1",
|
|
||||||
name: "Dave Staffel 1",
|
|
||||||
outputDir: "C:\\Downloads\\Dave Staffel 1",
|
|
||||||
extractDir: "C:\\Extract\\Dave Staffel 1",
|
|
||||||
status: "queued",
|
|
||||||
itemIds: ["item-1", "item-2"],
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
},
|
|
||||||
"pkg-2": {
|
|
||||||
id: "pkg-2",
|
|
||||||
name: "Andere Staffel",
|
|
||||||
outputDir: "C:\\Downloads\\Andere Staffel",
|
|
||||||
extractDir: "C:\\Extract\\Andere Staffel",
|
|
||||||
status: "queued",
|
|
||||||
itemIds: ["item-3"],
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
"item-1": {
|
|
||||||
id: "item-1",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
url: "https://example.com/e01",
|
|
||||||
provider: null,
|
|
||||||
status: "queued",
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
totalBytes: null,
|
|
||||||
progressPercent: 0,
|
|
||||||
fileName: "Dave.S01E01.rar",
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "Wartet",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
},
|
|
||||||
"item-2": {
|
|
||||||
id: "item-2",
|
|
||||||
packageId: "pkg-1",
|
|
||||||
url: "https://example.com/e02",
|
|
||||||
provider: null,
|
|
||||||
status: "queued",
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
totalBytes: null,
|
|
||||||
progressPercent: 0,
|
|
||||||
fileName: "Dave.S01E02.rar",
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "Wartet",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
},
|
|
||||||
"item-3": {
|
|
||||||
id: "item-3",
|
|
||||||
packageId: "pkg-2",
|
|
||||||
url: "https://example.com/other",
|
|
||||||
provider: null,
|
|
||||||
status: "queued",
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
totalBytes: null,
|
|
||||||
progressPercent: 0,
|
|
||||||
fileName: "Andere.S01E01.rar",
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "Wartet",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
runStartedAt: 0,
|
|
||||||
totalDownloadedBytes: 0,
|
|
||||||
summaryText: "",
|
|
||||||
reconnectUntil: 0,
|
|
||||||
reconnectReason: "",
|
|
||||||
paused: false,
|
|
||||||
running: false,
|
|
||||||
updatedAt: 1
|
|
||||||
},
|
|
||||||
summary: null,
|
|
||||||
stats: {
|
|
||||||
totalDownloaded: 0,
|
|
||||||
totalDownloadedAllTime: 0,
|
|
||||||
totalFilesSession: 0,
|
|
||||||
totalFilesAllTime: 0,
|
|
||||||
totalPackages: 2,
|
|
||||||
sessionStartedAt: 0,
|
|
||||||
appSessionStartedAt: 0,
|
|
||||||
sessionRuntimeMs: 0,
|
|
||||||
totalRuntimeMs: 0,
|
|
||||||
runtimeMeasuredAt: 0
|
|
||||||
},
|
|
||||||
speedText: "",
|
|
||||||
etaText: "",
|
|
||||||
canStart: true,
|
|
||||||
canStop: false,
|
|
||||||
canPause: false,
|
|
||||||
clipboardActive: false,
|
|
||||||
reconnectSeconds: 0,
|
|
||||||
packageSpeedBps: {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("link-export", () => {
|
|
||||||
it("keeps original package names when exporting selected items", () => {
|
|
||||||
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-3"]);
|
|
||||||
expect(selection.packageCount).toBe(2);
|
|
||||||
expect(selection.linkCount).toBe(2);
|
|
||||||
expect(selection.packages.map((pkg) => pkg.name)).toEqual(["Dave Staffel 1", "Andere Staffel"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("roundtrips exported text back into parsed package inputs", () => {
|
|
||||||
const selection = buildLinkExportSelection(buildSnapshot(), [], ["item-1", "item-2"]);
|
|
||||||
const text = serializeLinkExportText(selection.packages);
|
|
||||||
const reparsed = parseCollectorInput(text, "");
|
|
||||||
|
|
||||||
expect(reparsed).toHaveLength(1);
|
|
||||||
expect(reparsed[0]?.name).toBe("Dave Staffel 1");
|
|
||||||
expect(reparsed[0]?.links).toEqual(["https://example.com/e01", "https://example.com/e02"]);
|
|
||||||
expect(reparsed[0]?.fileNames).toEqual(["Dave.S01E01.rar", "Dave.S01E02.rar"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -8,15 +8,15 @@ describe("link-parser", () => {
|
|||||||
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
||||||
{ name: "Package B", links: ["http://link3"] },
|
{ name: "Package B", links: ["http://link3"] },
|
||||||
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
||||||
{ name: "", links: ["http://link5"] }
|
{ name: "", links: ["http://link5"] } // empty name will be inferred
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = mergePackageInputs(input);
|
const result = mergePackageInputs(input);
|
||||||
|
|
||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
|
||||||
|
|
||||||
const pkgA = result.find(p => p.name === "Package A");
|
const pkgA = result.find(p => p.name === "Package A");
|
||||||
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]);
|
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
|
||||||
|
|
||||||
const pkgB = result.find(p => p.name === "Package B");
|
const pkgB = result.find(p => p.name === "Package B");
|
||||||
expect(pkgB?.links).toEqual(["http://link3"]);
|
expect(pkgB?.links).toEqual(["http://link3"]);
|
||||||
@ -30,20 +30,9 @@ describe("link-parser", () => {
|
|||||||
|
|
||||||
const result = mergePackageInputs(input);
|
const result = mergePackageInputs(input);
|
||||||
|
|
||||||
|
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
|
||||||
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves file name hints when merging packages", () => {
|
|
||||||
const input = [
|
|
||||||
{ name: "Package A", links: ["http://link1", "http://link2"], fileNames: ["one.rar", "two.rar"] },
|
|
||||||
{ name: "Package A", links: ["http://link3", "http://link1"], fileNames: ["three.rar", "ignored.rar"] }
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = mergePackageInputs(input);
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]?.links).toEqual(["http://link1", "http://link2", "http://link3"]);
|
|
||||||
expect(result[0]?.fileNames).toEqual(["one.rar", "two.rar", "three.rar"]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseCollectorInput", () => {
|
describe("parseCollectorInput", () => {
|
||||||
@ -66,6 +55,7 @@ describe("link-parser", () => {
|
|||||||
|
|
||||||
const result = parseCollectorInput(rawText, "DefaultFallback");
|
const result = parseCollectorInput(rawText, "DefaultFallback");
|
||||||
|
|
||||||
|
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
||||||
@ -74,7 +64,7 @@ describe("link-parser", () => {
|
|||||||
"http://example.com/part2.rar"
|
"http://example.com/part2.rar"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const customPkg = result.find(p => p.name === "Custom_Name");
|
const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
|
||||||
expect(customPkg?.links).toEqual([
|
expect(customPkg?.links).toEqual([
|
||||||
"http://other.com/file1",
|
"http://other.com/file1",
|
||||||
"http://other.com/file2"
|
"http://other.com/file2"
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { logTimestamp } from "../src/main/log-timestamp";
|
|
||||||
|
|
||||||
describe("logTimestamp", () => {
|
|
||||||
it("formats local time with an explicit UTC offset (ISO 8601), not a UTC 'Z' string", () => {
|
|
||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
|
||||||
const formatted = logTimestamp(instant);
|
|
||||||
|
|
||||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
|
||||||
expect(formatted.endsWith("Z")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
|
|
||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
|
||||||
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
|
|
||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
|
||||||
const formatted = logTimestamp(instant);
|
|
||||||
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import {
|
|
||||||
decryptMegaAttributes,
|
|
||||||
isMegaFileUrl,
|
|
||||||
parseMegaUrl,
|
|
||||||
resolveMegaFilename
|
|
||||||
} from "../src/main/mega-public-api";
|
|
||||||
|
|
||||||
function base64Url(buf: Buffer): string {
|
|
||||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRandomFileKey(): Buffer {
|
|
||||||
return crypto.randomBytes(32);
|
|
||||||
}
|
|
||||||
|
|
||||||
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
|
||||||
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
|
||||||
const padded = Buffer.from(plain, "utf8");
|
|
||||||
const padLen = (16 - (padded.length % 16)) % 16;
|
|
||||||
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
|
||||||
const cipher = crypto.createCipheriv("aes-128-cbc", aesKey, Buffer.alloc(16));
|
|
||||||
cipher.setAutoPadding(false);
|
|
||||||
const enc = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
||||||
return base64Url(enc);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("mega-public-api", () => {
|
|
||||||
describe("isMegaFileUrl", () => {
|
|
||||||
it("recognizes new format", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo")).toBe(true);
|
|
||||||
});
|
|
||||||
it("recognizes legacy format", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/#!abc123!def456")).toBe(true);
|
|
||||||
});
|
|
||||||
it("recognizes mega.co.nz", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.co.nz/file/abc#xyz")).toBe(true);
|
|
||||||
});
|
|
||||||
it("rejects folder URLs", () => {
|
|
||||||
expect(isMegaFileUrl("https://mega.nz/folder/abc#xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects non-mega URLs", () => {
|
|
||||||
expect(isMegaFileUrl("https://example.com/file/abc#xyz")).toBe(false);
|
|
||||||
});
|
|
||||||
it("rejects garbage", () => {
|
|
||||||
expect(isMegaFileUrl("")).toBe(false);
|
|
||||||
expect(isMegaFileUrl("foo")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseMegaUrl", () => {
|
|
||||||
it("parses new-format URL into id + 32-byte key", () => {
|
|
||||||
const url = "https://mega.nz/file/pZl1wBRQ#BFx-HachDy4o9EgKy90IiLMsw3idHFGaDoJhajK5zzo";
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
expect(parsed).not.toBeNull();
|
|
||||||
expect(parsed?.id).toBe("pZl1wBRQ");
|
|
||||||
expect(parsed?.rawKey.length).toBe(32);
|
|
||||||
});
|
|
||||||
it("parses legacy-format URL", () => {
|
|
||||||
const id = "abcDEF12";
|
|
||||||
const key = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
|
||||||
const parsed = parseMegaUrl(url);
|
|
||||||
expect(parsed?.id).toBe(id);
|
|
||||||
expect(parsed?.rawKey.equals(key)).toBe(true);
|
|
||||||
});
|
|
||||||
it("rejects URL with folder key (16 bytes)", () => {
|
|
||||||
const url = `https://mega.nz/file/abc#${base64Url(crypto.randomBytes(16))}`;
|
|
||||||
expect(parseMegaUrl(url)).toBeNull();
|
|
||||||
});
|
|
||||||
it("rejects malformed URLs", () => {
|
|
||||||
expect(parseMegaUrl("not-a-url")).toBeNull();
|
|
||||||
expect(parseMegaUrl("https://mega.nz/file/abc")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decryptMegaAttributes", () => {
|
|
||||||
it("round-trips encrypted Mega attributes", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
const original = { n: "Test.S01E01.German.1080p.WEB.x264-DEMO.mkv", c: "ignored" };
|
|
||||||
const enc = encryptAttributes(original, aesKey);
|
|
||||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
|
||||||
const decrypted = decryptMegaAttributes(decoded, aesKey);
|
|
||||||
expect(decrypted).not.toBeNull();
|
|
||||||
expect(decrypted?.n).toBe(original.n);
|
|
||||||
});
|
|
||||||
it("returns null for wrong key", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
const wrongKey = crypto.randomBytes(16);
|
|
||||||
const enc = encryptAttributes({ n: "x" }, aesKey);
|
|
||||||
const decoded = Buffer.from(enc + "=".repeat((4 - (enc.length % 4)) % 4), "base64");
|
|
||||||
expect(decryptMegaAttributes(decoded, wrongKey)).toBeNull();
|
|
||||||
});
|
|
||||||
it("returns null for non-multiple-of-16 input", () => {
|
|
||||||
const aesKey = crypto.randomBytes(16);
|
|
||||||
expect(decryptMegaAttributes(Buffer.alloc(15), aesKey)).toBeNull();
|
|
||||||
});
|
|
||||||
it("returns null for wrong key length", () => {
|
|
||||||
expect(decryptMegaAttributes(Buffer.alloc(16), Buffer.alloc(8))).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveMegaFilename (mocked fetch)", () => {
|
|
||||||
let originalFetch: typeof fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
originalFetch = global.fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
global.fetch = originalFetch;
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns filename + size for a valid Mega response", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const aesKey = fileKey.subarray(0, 16);
|
|
||||||
const url = `https://mega.nz/file/testId12#${base64Url(fileKey)}`;
|
|
||||||
const encrypted = encryptAttributes(
|
|
||||||
{ n: "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv" },
|
|
||||||
aesKey
|
|
||||||
);
|
|
||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return [{ s: 1234567890, at: encrypted, msd: 1 }];
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await resolveMegaFilename(url);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
expect(result?.name).toBe("Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv");
|
|
||||||
expect(result?.size).toBe(1234567890);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when Mega returns numeric error", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return -9;
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when response is array with error code", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/blockedId#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
async json() {
|
|
||||||
return [-16];
|
|
||||||
}
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when fetch throws", async () => {
|
|
||||||
const fileKey = makeRandomFileKey();
|
|
||||||
const url = `https://mega.nz/file/networkFail#${base64Url(fileKey)}`;
|
|
||||||
global.fetch = vi.fn().mockRejectedValue(new Error("network down"));
|
|
||||||
expect(await resolveMegaFilename(url)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for non-mega URL without making any fetch call", async () => {
|
|
||||||
const fetchSpy = vi.fn();
|
|
||||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
|
||||||
expect(await resolveMegaFilename("https://example.com/file/abc#xyz")).toBeNull();
|
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -33,6 +33,7 @@ describe("mega-web-fallback", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("form=debrid")) {
|
if (urlStr.includes("form=debrid")) {
|
||||||
|
// The POST to generate the code
|
||||||
return new Response(`
|
return new Response(`
|
||||||
<div class="acp-box">
|
<div class="acp-box">
|
||||||
<h3>Link: https://mega.debrid/link1</h3>
|
<h3>Link: https://mega.debrid/link1</h3>
|
||||||
@ -42,6 +43,7 @@ describe("mega-web-fallback", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
if (urlStr.includes("ajax=debrid")) {
|
||||||
|
// Polling endpoint
|
||||||
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,98 +56,15 @@ describe("mega-web-fallback", () => {
|
|||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.directUrl).toBe("https://mega.direct/123");
|
expect(result?.directUrl).toBe("https://mega.direct/123");
|
||||||
expect(result?.fileName).toBe("link1");
|
expect(result?.fileName).toBe("link1");
|
||||||
|
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
|
||||||
expect(fetchCallCount).toBe(4);
|
expect(fetchCallCount).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails fast on 'Kein Server für diesen Hoster' (account hoster quota) instead of re-login + re-poll", async () => {
|
|
||||||
let ajaxCalls = 0;
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
||||||
const urlStr = String(url);
|
|
||||||
if (urlStr.includes("form=login")) {
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("page=debrideur")) {
|
|
||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("form=debrid")) {
|
|
||||||
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
|
||||||
ajaxCalls += 1;
|
|
||||||
return new Response(JSON.stringify({ link: "", text: "Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal." }), { status: 200 });
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
|
||||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
|
||||||
expect(ajaxCalls).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
|
||||||
let ajaxCalls = 0;
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
|
||||||
const urlStr = String(url);
|
|
||||||
if (urlStr.includes("form=login")) {
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("page=debrideur")) {
|
|
||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("form=debrid")) {
|
|
||||||
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
|
||||||
ajaxCalls += 1;
|
|
||||||
return new Response(JSON.stringify({ link: "https://should.not/happen" }), { status: 200 });
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
|
||||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
|
||||||
expect(ajaxCalls).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs in with the per-account credentials passed to unrestrict, not the default", async () => {
|
|
||||||
const loginsUsed: string[] = [];
|
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request, opts?: { body?: unknown }) => {
|
|
||||||
const urlStr = String(url);
|
|
||||||
if (urlStr.includes("form=login")) {
|
|
||||||
const params = new URLSearchParams(String(opts?.body ?? ""));
|
|
||||||
loginsUsed.push(params.get("login") || "");
|
|
||||||
const headers = new Headers();
|
|
||||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
|
||||||
return new Response("", { headers, status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("page=debrideur")) {
|
|
||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("form=debrid")) {
|
|
||||||
return new Response(`<div class="acp-box"><h3>Link: https://mega.debrid/l1</h3><a href="javascript:processDebrid(1,'code1',0)">d</a></div>`, { status: 200 });
|
|
||||||
}
|
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
|
||||||
return new Response(JSON.stringify({ link: "https://mega.direct/ok" }), { status: 200 });
|
|
||||||
}
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}) as unknown as typeof fetch;
|
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
|
||||||
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
|
||||||
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
|
||||||
expect(loginsUsed).toContain("account2");
|
|
||||||
expect(loginsUsed).not.toContain("defaultacc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws if login fails to set cookie", async () => {
|
it("throws if login fails to set cookie", async () => {
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("form=login")) {
|
if (urlStr.includes("form=login")) {
|
||||||
const headers = new Headers();
|
const headers = new Headers(); // No cookie
|
||||||
return new Response("", { headers, status: 200 });
|
return new Response("", { headers, status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
@ -166,6 +85,7 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response("", { headers, status: 200 });
|
return new Response("", { headers, status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("page=debrideur")) {
|
if (urlStr.includes("page=debrideur")) {
|
||||||
|
// Missing form!
|
||||||
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
@ -191,6 +111,7 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("form=debrid")) {
|
if (urlStr.includes("form=debrid")) {
|
||||||
|
// The generate POST returns HTML without any codes
|
||||||
return new Response(`<div>No links here</div>`, { status: 200 });
|
return new Response(`<div>No links here</div>`, { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
@ -199,6 +120,7 @@ describe("mega-web-fallback", () => {
|
|||||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||||
const result = await fallback.unrestrict("http://mega.debrid/file");
|
const result = await fallback.unrestrict("http://mega.debrid/file");
|
||||||
|
|
||||||
|
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { ensurePackageLog, getPackageLogPath, initPackageLogs, logPackageEvent, shutdownPackageLogs } from "../src/main/package-log";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownPackageLogs();
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("package-log", () => {
|
|
||||||
it("creates a persistent package log file", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initPackageLogs(baseDir);
|
|
||||||
const logPath = ensurePackageLog({
|
|
||||||
packageId: "pkg-1",
|
|
||||||
name: "Test Paket",
|
|
||||||
outputDir: "C:\\downloads\\Test Paket",
|
|
||||||
extractDir: "C:\\extract\\Test Paket"
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
expect(fs.existsSync(logPath!)).toBe(true);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Paket-Log Start");
|
|
||||||
expect(content).toContain("Test Paket");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes detail events into the package log", async () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initPackageLogs(baseDir);
|
|
||||||
ensurePackageLog({
|
|
||||||
packageId: "pkg-2",
|
|
||||||
name: "Detail Paket",
|
|
||||||
outputDir: "C:\\downloads\\Detail Paket",
|
|
||||||
extractDir: "C:\\extract\\Detail Paket"
|
|
||||||
});
|
|
||||||
|
|
||||||
logPackageEvent("pkg-2", "INFO", "Passwort-Versuch", {
|
|
||||||
archive: "episode.part1.rar",
|
|
||||||
attempt: "1/3",
|
|
||||||
password: "\"secret\""
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
||||||
|
|
||||||
const logPath = getPackageLogPath("pkg-2");
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Passwort-Versuch");
|
|
||||||
expect(content).toContain("archive=episode.part1.rar");
|
|
||||||
expect(content).toContain("password=\"secret\"");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps traversal-like package ids inside the package log directory", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-plog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initPackageLogs(baseDir);
|
|
||||||
const logPath = ensurePackageLog({
|
|
||||||
packageId: "..\\..\\outside",
|
|
||||||
name: "Traversal Paket",
|
|
||||||
outputDir: "C:\\downloads\\Traversal Paket",
|
|
||||||
extractDir: "C:\\extract\\Traversal Paket"
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
const logsDir = path.resolve(path.join(baseDir, "package-logs"));
|
|
||||||
const resolvedLogPath = path.resolve(logPath!);
|
|
||||||
expect(resolvedLogPath === logsDir || resolvedLogPath.startsWith(`${logsDir}${path.sep}`)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { DownloadItem, PackageEntry } from "../src/shared/types";
|
|
||||||
import { sortPackagesForDisplay } from "../src/renderer/package-order";
|
|
||||||
|
|
||||||
function createPackage(id: string, itemIds: string[]): PackageEntry {
|
|
||||||
const now = Date.now();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: id,
|
|
||||||
outputDir: "",
|
|
||||||
extractDir: "",
|
|
||||||
status: "queued",
|
|
||||||
itemIds,
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
priority: "normal",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createItem(id: string, packageId: string, status: DownloadItem["status"], downloadedBytes: number): DownloadItem {
|
|
||||||
const now = Date.now();
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
packageId,
|
|
||||||
url: `https://hoster.example/${id}`,
|
|
||||||
provider: null,
|
|
||||||
status,
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes,
|
|
||||||
totalBytes: downloadedBytes,
|
|
||||||
progressPercent: downloadedBytes > 0 ? 50 : 0,
|
|
||||||
fileName: `${id}.bin`,
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "",
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("sortPackagesForDisplay", () => {
|
|
||||||
it("floats active packages to the top, keeping queue order within each group", () => {
|
|
||||||
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
|
|
||||||
// their original queue order; pkg-c (queued only) sinks below.
|
|
||||||
const packages = [
|
|
||||||
createPackage("pkg-a", ["a1", "a2"]),
|
|
||||||
createPackage("pkg-c", ["c1"]),
|
|
||||||
createPackage("pkg-b", ["b1", "b2"])
|
|
||||||
];
|
|
||||||
const items: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 250),
|
|
||||||
a2: createItem("a2", "pkg-a", "completed", 500),
|
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 800),
|
|
||||||
b2: createItem("b2", "pkg-b", "completed", 900)
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, true);
|
|
||||||
|
|
||||||
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c]
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
|
|
||||||
const packages = [
|
|
||||||
createPackage("pkg-a", ["a1"]),
|
|
||||||
createPackage("pkg-b", ["b1"])
|
|
||||||
];
|
|
||||||
// Both active. pkg-b initially has more bytes than pkg-a.
|
|
||||||
const before: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 100),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 900)
|
|
||||||
};
|
|
||||||
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
|
|
||||||
|
|
||||||
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
|
|
||||||
// both are still active, so they keep queue order. (Old code swapped them.)
|
|
||||||
const after: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "downloading", 5000),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 950)
|
|
||||||
};
|
|
||||||
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
|
|
||||||
|
|
||||||
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
|
|
||||||
expect(orderAfter).toEqual(orderBefore);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps package order untouched when auto sort is disabled", () => {
|
|
||||||
const packages = [
|
|
||||||
createPackage("pkg-a", ["a1"]),
|
|
||||||
createPackage("pkg-b", ["b1"]),
|
|
||||||
createPackage("pkg-c", ["c1"])
|
|
||||||
];
|
|
||||||
const items: Record<string, DownloadItem> = {
|
|
||||||
a1: createItem("a1", "pkg-a", "queued", 0),
|
|
||||||
b1: createItem("b1", "pkg-b", "downloading", 500),
|
|
||||||
c1: createItem("c1", "pkg-c", "queued", 0)
|
|
||||||
};
|
|
||||||
|
|
||||||
const sorted = sortPackagesForDisplay(packages, items, true, false);
|
|
||||||
|
|
||||||
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const {
|
|
||||||
mockSessionFetch,
|
|
||||||
mockClearStorageData,
|
|
||||||
mockClearCache,
|
|
||||||
mockFromPartition,
|
|
||||||
mockBrowserWindow,
|
|
||||||
mockBrowserWindowCtor,
|
|
||||||
mockExecuteJavaScript,
|
|
||||||
mockLoadURL,
|
|
||||||
mockShow,
|
|
||||||
mockFocus
|
|
||||||
} = vi.hoisted(() => {
|
|
||||||
const sessionFetch = vi.fn();
|
|
||||||
const clearStorageData = vi.fn();
|
|
||||||
const clearCache = vi.fn();
|
|
||||||
const fromPartition = vi.fn();
|
|
||||||
const executeJavaScript = vi.fn();
|
|
||||||
const loadURL = vi.fn(async () => {});
|
|
||||||
const show = vi.fn();
|
|
||||||
const focus = vi.fn();
|
|
||||||
const webContentsEvents: Record<string, (...args: unknown[]) => void> = {};
|
|
||||||
const windowEvents: Record<string, (...args: unknown[]) => void> = {};
|
|
||||||
let destroyed = false;
|
|
||||||
|
|
||||||
const browserWindow = {
|
|
||||||
isDestroyed: vi.fn(() => destroyed),
|
|
||||||
isMinimized: vi.fn(() => false),
|
|
||||||
restore: vi.fn(),
|
|
||||||
show,
|
|
||||||
focus,
|
|
||||||
close: vi.fn(() => {
|
|
||||||
destroyed = true;
|
|
||||||
windowEvents.closed?.();
|
|
||||||
}),
|
|
||||||
setMenuBarVisibility: vi.fn(),
|
|
||||||
loadURL,
|
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
windowEvents[event] = handler;
|
|
||||||
return browserWindow;
|
|
||||||
}),
|
|
||||||
webContents: {
|
|
||||||
setUserAgent: vi.fn(),
|
|
||||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
|
||||||
webContentsEvents[event] = handler;
|
|
||||||
}),
|
|
||||||
executeJavaScript
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const BrowserWindowCtor = vi.fn(() => {
|
|
||||||
destroyed = false;
|
|
||||||
return browserWindow;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mockSessionFetch: sessionFetch,
|
|
||||||
mockClearStorageData: clearStorageData,
|
|
||||||
mockClearCache: clearCache,
|
|
||||||
mockFromPartition: fromPartition,
|
|
||||||
mockBrowserWindow: browserWindow,
|
|
||||||
mockBrowserWindowCtor: BrowserWindowCtor,
|
|
||||||
mockExecuteJavaScript: executeJavaScript,
|
|
||||||
mockLoadURL: loadURL,
|
|
||||||
mockShow: show,
|
|
||||||
mockFocus: focus
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("electron", () => ({
|
|
||||||
session: {
|
|
||||||
fromPartition: mockFromPartition
|
|
||||||
},
|
|
||||||
BrowserWindow: mockBrowserWindowCtor
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { RealDebridWebFallback, extractPrivateTokenFromHtml } from "../src/main/realdebrid-web";
|
|
||||||
|
|
||||||
describe("realdebrid-web", () => {
|
|
||||||
const mockSession = {
|
|
||||||
fetch: mockSessionFetch,
|
|
||||||
clearStorageData: mockClearStorageData,
|
|
||||||
clearCache: mockClearCache
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFromPartition.mockReturnValue(mockSession);
|
|
||||||
mockExecuteJavaScript.mockReset();
|
|
||||||
mockLoadURL.mockClear();
|
|
||||||
mockShow.mockClear();
|
|
||||||
mockFocus.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockFromPartition.mockReturnValue(mockSession);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extracts private tokens from current Real-Debrid HTML patterns", () => {
|
|
||||||
expect(extractPrivateTokenFromHtml("document.querySelectorAll('input[name=private_token]')[0].value = 'abc123';"))
|
|
||||||
.toBe("abc123");
|
|
||||||
expect(extractPrivateTokenFromHtml("<input type=\"text\" name=\"private_token\" value=\"def456\">"))
|
|
||||||
.toBe("def456");
|
|
||||||
expect(extractPrivateTokenFromHtml("<input value=\"ghi789\" name=\"private_token\">"))
|
|
||||||
.toBe("ghi789");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the already logged-in browser window to warm the token cache before unrestricting", async () => {
|
|
||||||
const apiFetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
||||||
download: "https://cdn.real-debrid.example/file.bin",
|
|
||||||
filename: "file.bin",
|
|
||||||
filesize: 12345
|
|
||||||
}), { status: 200 }));
|
|
||||||
vi.stubGlobal("fetch", apiFetch);
|
|
||||||
|
|
||||||
mockExecuteJavaScript.mockResolvedValue("token-from-window");
|
|
||||||
|
|
||||||
const fallback = new RealDebridWebFallback(() => true);
|
|
||||||
await fallback.openLoginWindow();
|
|
||||||
|
|
||||||
const result = await fallback.unrestrict("https://rapidgator.net/file/abc");
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
directUrl: "https://cdn.real-debrid.example/file.bin",
|
|
||||||
fileName: "file.bin",
|
|
||||||
fileSize: 12345,
|
|
||||||
retriesUsed: 0
|
|
||||||
});
|
|
||||||
expect(mockBrowserWindowCtor).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockLoadURL).toHaveBeenCalledWith("https://real-debrid.com");
|
|
||||||
expect(mockShow).toHaveBeenCalled();
|
|
||||||
expect(mockFocus).toHaveBeenCalled();
|
|
||||||
expect(mockSessionFetch).not.toHaveBeenCalled();
|
|
||||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
|
||||||
expect(apiFetch.mock.calls[0]?.[0]).toBe("https://api.real-debrid.com/rest/1.0/unrestrict/link");
|
|
||||||
expect(mockBrowserWindow.webContents.executeJavaScript).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { getRenameLogPath, initRenameLog, logRenameEvent, shutdownRenameLog } from "../src/main/rename-log";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownRenameLog();
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("rename-log", () => {
|
|
||||||
it("writes rename events to the rename log", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
initRenameLog(baseDir);
|
|
||||||
logRenameEvent("INFO", "Auto-Rename durchgeführt", {
|
|
||||||
packageName: "Test Paket",
|
|
||||||
sourcePath: "C:\\extract\\old.mkv",
|
|
||||||
targetPath: "C:\\extract\\new.mkv"
|
|
||||||
});
|
|
||||||
|
|
||||||
const logPath = getRenameLogPath();
|
|
||||||
expect(logPath).not.toBeNull();
|
|
||||||
expect(fs.existsSync(logPath!)).toBe(true);
|
|
||||||
const content = fs.readFileSync(logPath!, "utf8");
|
|
||||||
expect(content).toContain("Rename-Log Start");
|
|
||||||
expect(content).toContain("Auto-Rename durchgeführt");
|
|
||||||
expect(content).toContain("sourcePath=C:\\extract\\old.mkv");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rotates oversized rename logs on startup", () => {
|
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rlog-rotate-"));
|
|
||||||
tempDirs.push(baseDir);
|
|
||||||
|
|
||||||
const oversizedPath = path.join(baseDir, "rename.log");
|
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
|
||||||
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
|
|
||||||
|
|
||||||
initRenameLog(baseDir);
|
|
||||||
|
|
||||||
expect(fs.existsSync(oversizedPath)).toBe(true);
|
|
||||||
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
|
|
||||||
const content = fs.readFileSync(oversizedPath, "utf8");
|
|
||||||
expect(content).toContain("Rename-Log Start");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -17,6 +17,7 @@ function makeItems(names: string[]): MinimalItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveArchiveItemsFromList", () => {
|
describe("resolveArchiveItemsFromList", () => {
|
||||||
|
// ── Multipart RAR (.partN.rar) ──
|
||||||
|
|
||||||
it("matches multipart .part1.rar archives", () => {
|
it("matches multipart .part1.rar archives", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
@ -45,6 +46,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
|
||||||
|
|
||||||
it("matches old-style .rar + .rNN volumes", () => {
|
it("matches old-style .rar + .rNN volumes", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Archive.rar",
|
"Archive.rar",
|
||||||
@ -57,6 +60,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Single RAR ──
|
||||||
|
|
||||||
it("matches a single .rar file", () => {
|
it("matches a single .rar file", () => {
|
||||||
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
||||||
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
||||||
@ -64,6 +69,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Split ZIP ──
|
||||||
|
|
||||||
it("matches split .zip.NNN files", () => {
|
it("matches split .zip.NNN files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Data.zip",
|
"Data.zip",
|
||||||
@ -75,6 +82,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Split 7z ──
|
||||||
|
|
||||||
it("matches split .7z.NNN files", () => {
|
it("matches split .7z.NNN files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Backup.7z.001",
|
"Backup.7z.001",
|
||||||
@ -84,6 +93,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Generic .NNN splits ──
|
||||||
|
|
||||||
it("matches generic .NNN split files", () => {
|
it("matches generic .NNN split files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"video.001",
|
"video.001",
|
||||||
@ -94,6 +105,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Exact filename match ──
|
||||||
|
|
||||||
it("matches a single .zip by exact name", () => {
|
it("matches a single .zip by exact name", () => {
|
||||||
const items = makeItems(["myarchive.zip", "other.rar"]);
|
const items = makeItems(["myarchive.zip", "other.rar"]);
|
||||||
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
||||||
@ -101,6 +114,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Case insensitivity ──
|
||||||
|
|
||||||
it("matches case-insensitively", () => {
|
it("matches case-insensitively", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"MOVIE.PART1.RAR",
|
"MOVIE.PART1.RAR",
|
||||||
@ -110,26 +125,40 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Stem-based fallback ──
|
||||||
|
|
||||||
it("uses stem-based fallback when exact patterns fail", () => {
|
it("uses stem-based fallback when exact patterns fail", () => {
|
||||||
|
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
|
||||||
|
// but the disk file is "Movie.part1.rar"
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Movie.rar",
|
"Movie.rar",
|
||||||
]);
|
]);
|
||||||
|
// The archive on disk is "Movie.part1.rar" but there's no item matching the
|
||||||
|
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
|
||||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||||
|
// stem fallback: "movie" starts with "movie" and ends with .rar
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Single item fallback ──
|
||||||
|
|
||||||
it("returns single archive item when no pattern matches", () => {
|
it("returns single archive item when no pattern matches", () => {
|
||||||
const items = makeItems(["totally-different-name.rar"]);
|
const items = makeItems(["totally-different-name.rar"]);
|
||||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||||
|
// Single item in list with archive extension → return it
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Empty when no match ──
|
||||||
|
|
||||||
it("returns empty when items have no archive extensions", () => {
|
it("returns empty when items have no archive extensions", () => {
|
||||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Items without targetPath ──
|
||||||
|
|
||||||
it("falls back to fileName when targetPath is missing", () => {
|
it("falls back to fileName when targetPath is missing", () => {
|
||||||
const items = [
|
const items = [
|
||||||
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
||||||
@ -139,6 +168,8 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Multiple archives, should not cross-match ──
|
||||||
|
|
||||||
it("does not cross-match different archive groups", () => {
|
it("does not cross-match different archive groups", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Episode.S01E01.part1.rar",
|
"Episode.S01E01.part1.rar",
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { pruneSelection } from "../src/renderer/selection";
|
|
||||||
import type { SessionState } from "../src/shared/types";
|
|
||||||
|
|
||||||
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
|
|
||||||
const packages: Record<string, never> = {};
|
|
||||||
const items: Record<string, never> = {};
|
|
||||||
for (const id of packageIds) packages[id] = {} as never;
|
|
||||||
for (const id of itemIds) items[id] = {} as never;
|
|
||||||
return { packages, items };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("pruneSelection", () => {
|
|
||||||
it("drops ids whose package/item no longer exists", () => {
|
|
||||||
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
|
|
||||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
|
||||||
expect([...next].sort()).toEqual(["i1", "p1"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
|
|
||||||
const sel = new Set(["p1", "i1"]);
|
|
||||||
const next = pruneSelection(sel, session(["p1"], ["i1"]));
|
|
||||||
expect(next).toBe(sel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the same instance for an empty selection", () => {
|
|
||||||
const sel = new Set<string>();
|
|
||||||
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes everything when the whole session was swapped out", () => {
|
|
||||||
const sel = new Set(["p1", "i1"]);
|
|
||||||
const next = pruneSelection(sel, session([], []));
|
|
||||||
expect(next.size).toBe(0);
|
|
||||||
expect(next).not.toBe(sel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a mixed package+item selection when both survive", () => {
|
|
||||||
const sel = new Set(["p1", "p2", "i1"]);
|
|
||||||
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
|
|
||||||
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
|
|
||||||
expect(next).toBe(sel); // unchanged → same instance
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -8,7 +8,9 @@ import { setLogListener } from "../src/main/logger";
|
|||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
// Ensure session log is shut down between tests
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
// Ensure listener is cleared between tests
|
||||||
setLogListener(null);
|
setLogListener(null);
|
||||||
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 });
|
||||||
@ -40,9 +42,11 @@ describe("session-log", () => {
|
|||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
const logPath = getSessionLogPath()!;
|
const logPath = getSessionLogPath()!;
|
||||||
|
|
||||||
|
// Simulate a log line via the listener
|
||||||
const { logger } = await import("../src/main/logger");
|
const { logger } = await import("../src/main/logger");
|
||||||
logger.info("Test-Nachricht für Session-Log");
|
logger.info("Test-Nachricht für Session-Log");
|
||||||
|
|
||||||
|
// Wait for flush (200ms interval + margin)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const content = fs.readFileSync(logPath, "utf8");
|
const content = fs.readFileSync(logPath, "utf8");
|
||||||
@ -73,6 +77,7 @@ describe("session-log", () => {
|
|||||||
|
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
|
||||||
|
// Log after shutdown - should NOT appear in session log
|
||||||
const { logger } = await import("../src/main/logger");
|
const { logger } = await import("../src/main/logger");
|
||||||
logger.info("Nach-Shutdown-Nachricht");
|
logger.info("Nach-Shutdown-Nachricht");
|
||||||
|
|
||||||
@ -89,16 +94,21 @@ describe("session-log", () => {
|
|||||||
const logsDir = path.join(baseDir, "session-logs");
|
const logsDir = path.join(baseDir, "session-logs");
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create a fake old session log
|
||||||
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
||||||
fs.writeFileSync(oldFile, "old session");
|
fs.writeFileSync(oldFile, "old session");
|
||||||
|
// Set mtime to 30 days ago
|
||||||
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(oldFile, oldTime, oldTime);
|
fs.utimesSync(oldFile, oldTime, oldTime);
|
||||||
|
|
||||||
|
// Create a recent file
|
||||||
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
||||||
fs.writeFileSync(newFile, "new session");
|
fs.writeFileSync(newFile, "new session");
|
||||||
|
|
||||||
|
// initSessionLog triggers cleanup
|
||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
|
|
||||||
|
// Wait for async cleanup
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
expect(fs.existsSync(oldFile)).toBe(false);
|
expect(fs.existsSync(oldFile)).toBe(false);
|
||||||
@ -114,6 +124,7 @@ describe("session-log", () => {
|
|||||||
const logsDir = path.join(baseDir, "session-logs");
|
const logsDir = path.join(baseDir, "session-logs");
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create a file from 2 days ago (should be kept)
|
||||||
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
||||||
fs.writeFileSync(recentFile, "recent session");
|
fs.writeFileSync(recentFile, "recent session");
|
||||||
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||||
@ -136,6 +147,7 @@ describe("session-log", () => {
|
|||||||
const path1 = getSessionLogPath();
|
const path1 = getSessionLogPath();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
|
||||||
|
// Small delay to ensure different timestamp
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
|
|||||||
@ -1,132 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
|
||||||
import { DownloadItem, PackageEntry, SessionState } from "../src/shared/types";
|
|
||||||
import {
|
|
||||||
cancelPendingAsyncSaves,
|
|
||||||
createStoragePaths,
|
|
||||||
emptySession,
|
|
||||||
loadSession,
|
|
||||||
saveSession,
|
|
||||||
saveSessionAsync
|
|
||||||
} from "../src/main/storage";
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
for (const dir of tempDirs.splice(0)) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function makePackage(id: string, itemId: string): PackageEntry {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: `Package ${id}`,
|
|
||||||
outputDir: "C:/tmp/out",
|
|
||||||
extractDir: "C:/tmp/extract",
|
|
||||||
status: "queued",
|
|
||||||
itemIds: [itemId],
|
|
||||||
cancelled: false,
|
|
||||||
enabled: true,
|
|
||||||
downloadStartedAt: 0,
|
|
||||||
downloadCompletedAt: 0,
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeItem(id: string, packageId: string): DownloadItem {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
packageId,
|
|
||||||
url: `https://example.com/${id}`,
|
|
||||||
provider: null,
|
|
||||||
status: "queued",
|
|
||||||
retries: 0,
|
|
||||||
speedBps: 0,
|
|
||||||
downloadedBytes: 0,
|
|
||||||
totalBytes: null,
|
|
||||||
progressPercent: 0,
|
|
||||||
fileName: `${id}.rar`,
|
|
||||||
targetPath: "",
|
|
||||||
resumable: true,
|
|
||||||
attempts: 0,
|
|
||||||
lastError: "",
|
|
||||||
fullStatus: "Wartet",
|
|
||||||
createdAt: 1,
|
|
||||||
updatedAt: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionWith(ids: string[]): SessionState {
|
|
||||||
const s = emptySession();
|
|
||||||
for (const id of ids) {
|
|
||||||
const itemId = `${id}-item`;
|
|
||||||
s.packageOrder.push(id);
|
|
||||||
s.packages[id] = makePackage(id, itemId);
|
|
||||||
s.items[itemId] = makeItem(itemId, id);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
const settle = (ms = 250): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
describe("session restart loss", () => {
|
|
||||||
it("does not let a queued stale async save clobber a newer synchronous save", async () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
|
|
||||||
cancelPendingAsyncSaves();
|
|
||||||
await settle(50);
|
|
||||||
|
|
||||||
saveSession(paths, sessionWith(["A", "B"]));
|
|
||||||
|
|
||||||
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
|
||||||
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
|
||||||
saveSession(paths, sessionWith(["A", "B", "C"]));
|
|
||||||
|
|
||||||
await inflight;
|
|
||||||
await queued;
|
|
||||||
await settle();
|
|
||||||
|
|
||||||
const loaded = loadSession(paths);
|
|
||||||
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B", "C"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers packages from the backup when the primary session file is absent", () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
|
|
||||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
|
||||||
expect(fs.existsSync(paths.sessionFile)).toBe(false);
|
|
||||||
|
|
||||||
const loaded = loadSession(paths);
|
|
||||||
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still treats a truly fresh install (no primary, no backup, no temp) as empty", () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
|
|
||||||
const loaded = loadSession(paths);
|
|
||||||
expect(Object.keys(loaded.packages)).toEqual([]);
|
|
||||||
expect(Object.keys(loaded.items)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers from the backup when the primary exists but is empty", () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-loss-"));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
const paths = createStoragePaths(dir);
|
|
||||||
|
|
||||||
fs.writeFileSync(paths.sessionFile, JSON.stringify(emptySession()), "utf8");
|
|
||||||
fs.writeFileSync(`${paths.sessionFile}.bak`, JSON.stringify(sessionWith(["A", "B"])), "utf8");
|
|
||||||
|
|
||||||
const loaded = loadSession(paths);
|
|
||||||
expect(Object.keys(loaded.packages).sort()).toEqual(["A", "B"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user